Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/pin-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: pin-sync
on: [push, pull_request]
permissions:
contents: read
concurrency:
group: pin-sync-${{ github.ref }}
cancel-in-progress: true
jobs:
pin-sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# check-pins.mjs resolves the release tag with `git rev-parse`
# -- a shallow checkout has no tags to resolve, so it would
# fail the tag-resolution precondition for the wrong reason.
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
- name: check-pins passes, and sees every example workflow
shell: bash
run: |
# `shell: bash` runs with -e, so without the guard a nonzero
# exit aborts on the next line and the checker's own message
# (the exit 2 / exit 3 diagnostics) never reaches the job log.
set +e
node scripts/check-pins.mjs > /tmp/pin-check-out.txt 2>&1
rc=$?
set -e
cat /tmp/pin-check-out.txt
if [ "$rc" -ne 0 ]; then
echo "FAIL: check-pins should exit 0 on a correctly-pinned tree (got $rc)"
exit 1
fi
# A regression that NARROWS the scan (fewer references found
# than exist) would still exit 0 above, and the single-file
# corruption step below only proves ONE reference is still
# seen -- neither catches partial blindness. The files checked
# are DERIVED, never a hard-coded count: every example workflow
# whose `uses:` line points at this action must be named in the
# output. The address is built from two halves for the same
# reason check-pins.mjs builds it that way.
# Cost: an example that lost its `uses:` line for this action
# entirely is no longer caught here. That is a broken example,
# a different defect, and never this checker's job.
owner=talongate
repo=move-test-gen
missing=0
for f in $(grep -lE "^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*$owner/$repo(/[^[:space:]@]*)?@" examples/workflows/*.yml); do
grep -qF "$f" /tmp/pin-check-out.txt || { echo "FAIL: $f is not named in check-pins output -- the scan may have narrowed"; missing=1; }
done
if [ "$missing" -ne 0 ]; then exit 1; fi
echo "check-pins saw every example workflow that references the action"
- name: check-pins must FAIL on a corrupted pin
shell: bash
run: |
# Derives the SHA to corrupt from the file itself rather than a
# SHA hard-coded here -- a hard-coded value goes stale on the
# very next release and this step would then corrupt nothing,
# still fail loud on the exit-code check below, but for the
# wrong reason (the sed pattern never matched, not "the checker
# caught a mismatch"). Built from two string halves for the
# same reason check-pins.mjs itself is: written as one literal
# address, this step's own tracked text would be a fourth
# reference in the PASSING run above.
node -e "
const fs = require('fs');
const f = 'examples/workflows/pr-gate.yml';
const owner = 'talongate', repo = 'move-test-gen';
const re = new RegExp(owner + '/' + repo + '@([0-9a-f]{40})');
const text = fs.readFileSync(f, 'utf8');
const m = text.match(re);
if (!m) throw new Error('no pinned SHA found to corrupt in ' + f);
fs.writeFileSync(f, text.replace(m[1], 'f'.repeat(40)));
"
set +e
node scripts/check-pins.mjs > /tmp/pin-check-out.txt 2>&1
rc=$?
set -e
cat /tmp/pin-check-out.txt
if [ "$rc" -eq 0 ]; then
echo "FAIL: a corrupted pin should make check-pins.mjs exit nonzero"
exit 1
fi
grep -q "MISMATCH" /tmp/pin-check-out.txt || { echo "FAIL: check-pins did not report the corruption"; exit 1; }
echo "check-pins exited $rc on a corrupted pin, as expected"
git checkout -- examples/workflows/pr-gate.yml
2 changes: 1 addition & 1 deletion examples/workflows/nightly-mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
sudo mv sui /usr/local/bin/
rm sui.tgz
sui --version
- uses: talongate/move-test-gen@3afcb1a731e058835c622475b6203b42a4b52d84 # v1.6.0
- uses: talongate/move-test-gen@f2efe3338348e5bd2238a752bdf00b1c3800dfd8 # v1.6.1
with:
sources: sources
tests: tests
Expand Down
2 changes: 1 addition & 1 deletion examples/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: talongate/move-test-gen@3afcb1a731e058835c622475b6203b42a4b52d84 # v1.6.0
- uses: talongate/move-test-gen@f2efe3338348e5bd2238a752bdf00b1c3800dfd8 # v1.6.1
with:
sources: sources
tests: tests
284 changes: 284 additions & 0 deletions scripts/check-pins.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
#!/usr/bin/env node
/**
* check-pins.mjs — verifies every reference to this action, anywhere in
* the tracked tree, is pinned to the RELEASE commit package.json's own
* "version" field names.
*
* Why this exists: the example workflows drifted to a PRIOR release's
* SHA after a version bump -- three times now (`7daae46`, `babc3fa`,
* and the pair this file was written to fix), and nothing re-checked it
* after the first two. A hard-coded file list rots the day a third
* example is added — the reference set below is DERIVED from
* `git ls-files`, never enumerated by hand.
*
* Usage:
* node scripts/check-pins.mjs
*
* Prints one line per reference found (file:line, OK or the specific
* mismatch) and a summary. Exit codes match this repo's own documented
* contract (README.md's "Exit codes" table, the one `scripts/lint.mjs`
* and `scripts/check-coverage.mjs` already share):
* 0 = every reference found checks out
* 1 = at least one reference is pinned to the wrong thing
* 2 = a broken precondition -- the release tag itself does not
* resolve (package.json's version has no matching tag yet: this
* is expected mid-release, between "bump the version" and "cut
* the tag" in RELEASE.md's own ordering, so it is reported as a
* precondition failure, never mistaken for exit 1's "a pin
* drifted")
* 3 = the tool ran but verified nothing -- no reference to the action
* was found anywhere in the tracked tree at all, which for a
* checker whose whole job is "did every found reference check
* out" is a vacuous pass wearing exit 0's clothes if left
* unguarded. This DOES mean a legitimately reference-free tree
* (a stripped fork, a vendored copy with no example workflows)
* goes red -- accepted here, because this repo's own README.md
* always carries one; the check is now also asserting "this repo
* documents its own action," which is worth saying once, here.
*/
import { readFileSync } from 'fs';
import { execFileSync } from 'child_process';

// The literal owner/repo this checker looks for is assembled from two
// halves rather than written as one string -- writing the whole
// "owner/repo@" address as a literal here would make THIS file's own
// source text match its own scan (this file is tracked too), which
// would misreport itself as a reference to classify.
const OWNER = 'talongate';
const REPO_NAME = 'move-test-gen';
// An optional `/<subpath>` between the repo name and `@` -- GitHub
// resolves `owner/repo/some/dir@ref` as a reusable action living in a
// subdirectory of this same repo, and it is meant to move in lockstep
// with the same release. Missing this made such a reference invisible
// to the scan below rather than merely permissive about it.
const REF_PREFIX = `${OWNER}/${REPO_NAME}`;
// The reference itself never contains whitespace (a YAML `uses:` value,
// or a bare address in prose) -- stopping at `#` too, not only
// whitespace, so `@<sha># v1.6.1` (a trailing comment with no space
// before it) captures the SHA cleanly instead of swallowing the `#` and
// everything after it into one unrecognizable token; TRAILING_COMMENT_RE
// below still finds that comment independently, against the whole line.
// The capture may be EMPTY (`*`, not `+`): `...@# v1.6.1` has no ref at
// all, and requiring one character made such a line invisible to the
// scan instead of reported. An empty token falls through classifyRef to
// "unrecognized". Cost: a bare `<owner>/<repo>@` in prose with nothing
// after it is now flagged too; that shape has no innocent reading here.
const REF_RE = new RegExp(`${REF_PREFIX.replace(/\//g, '\\/')}(?:\\/[^\\s@]+)?@([^\\s#]*)`, 'g');
const SHA_RE = /^[0-9a-f]{40}$/i;
// The core `vX.Y.Z` is captured on its own; an optional SemVer
// pre-release/build suffix (`-beta.1`, `+build.5`) is recognized as part
// of the TAG SHAPE so such a reference is compared by version and
// reported as a precise mismatch, rather than falling into the
// catch-all "neither a SHA nor a tag" bucket with a message that names
// the wrong problem.
const TAG_RE = /^v(\d+\.\d+\.\d+)(?:[-+][0-9A-Za-z.-]+)?$/;
// A trailing `# vX.Y.Z` comment on the SAME line as a reference,
// anchored to end-of-line so it only matches the comment that actually
// follows THIS reference, never an unrelated `#` earlier on the line.
const TRAILING_COMMENT_RE = /#\s*v(\d+\.\d+\.\d+)\s*$/;

/**
* Trailing punctuation a human naturally follows a reference with in
* prose or markdown, stripped before classifying: a sentence-ending
* period, a comma before "or", a closing paren/bracket, a backtick or
* quote closing an inline code span, or `**` closing bold. None of
* these can appear inside a genuine SHA ([0-9a-f]) or tag
* (vX.Y.Z[-+...]), so stripping them never turns a real mismatch into a
* false OK.
*
* The one accepted trade, named rather than silently taken: this also
* forgives a genuinely MALFORMED YAML value like `uses: ...@v1.6.1)` --
* an unbalanced paren that should not compile as a workflow at all. YAML
* itself does not produce that shape from valid `uses:` syntax, and the
* alternative (leaving every markdown mention of this action flagged
* red on correct documentation) is the worse failure for a hygiene
* checker: a gate that fires on compliant work teaches its reader to
* ignore it.
*/
function stripTrailingPunctuation(token) {
return token.replace(/[.,;:)\]`*"']+$/, '');
}

/**
* Read package.json's declared version and resolve it to the tag's
* commit. Throws (never returns a partial/empty result) if the tag does
* not exist -- a version bump with no matching tag means there is no
* release SHA to check anything against, so the whole run is void, not
* merely one finding short. main() maps this to exit 2, per the file
* header's own exit-code contract.
*/
function readReleaseInfo() {
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
const version = pkg.version;
if (!version) throw new Error('package.json has no "version" field');
const tag = `v${version}`;
let sha;
try {
// `^{commit}` peels an annotated tag to the commit it points at; a
// lightweight tag already IS the commit, and peeling one is a no-op.
// `stdio: ['ignore', 'pipe', 'pipe']` -- Node's execFileSync default
// still lets a failing child's stderr print DIRECTLY to our own
// stderr (measured: git's own "fatal: ambiguous argument..." leaks
// through alongside the one clean line below) even though it is
// ALSO captured into err.stderr for the catch block. Suppressing the
// direct pass-through keeps the surface to the one line the catch
// block below prints -- the same "loud but not a stack trace" bar
// this file's whole header exists to hold.
sha = execFileSync('git', ['rev-parse', `${tag}^{commit}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
} catch (err) {
// This catch sees EVERY way `git rev-parse` can fail, not only a
// missing tag (not a repo, git absent, a locked or corrupt repo), so
// the usual-cause explanation is a hypothesis and git's own first
// line rides beside it as the evidence -- still one line.
const said = String(err.stderr || '').trim().split('\n')[0] || err.code || 'no output';
throw new Error(
`tag ${tag} does not resolve to a commit -- package.json declares version ${version}; the usual causes are a checkout without tags (needs fetch-depth: 0) or a version bumped before the tag was cut (see RELEASE.md's own ordering); git said: ${said}`,
{ cause: err },
);
}
return { version, tag, sha };
}

/** git's own binary heuristic: a NUL byte anywhere in the content. */
function isBinary(buf) {
return buf.includes(0);
}

/**
* Every tracked reference to the action, derived from `git ls-files`
* rather than a hard-coded list of "the two example workflows" -- see
* the file header for why a derived set is the entire point.
*/
function findReferences() {
const files = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' })
.split('\0')
.filter(Boolean);

const refs = [];
for (const file of files) {
let buf;
try {
buf = readFileSync(file);
} catch {
// Tracked in the index but not present on disk (a submodule
// gitlink, or a path deleted-but-not-yet-committed) -- not a real
// file to scan, and not this checker's job to diagnose.
continue;
}
if (isBinary(buf)) continue;
const lines = buf.toString('utf8').split('\n');
for (let i = 0; i < lines.length; i++) {
REF_RE.lastIndex = 0;
let m;
while ((m = REF_RE.exec(lines[i]))) {
refs.push({ file, line: i + 1, ref: stripTrailingPunctuation(m[1]), lineText: lines[i] });
}
}
}
return refs;
}

/**
* Classify one found reference against the release info from
* readReleaseInfo(). Three shapes, per the file header's own spec:
* - a 40-hex SHA, optionally with a trailing `# vX.Y.Z` comment
* - a `vX.Y.Z` tag, optionally with a SemVer pre-release/build suffix
* - anything else -- a branch name, `@main`, a short SHA -- reported
* as unpinned/unrecognized, which counts as a failure: an
* unrecognized reference cannot be verified as the release, so it
* has not "checked out" per this file's own exit contract.
*
* The trailing-comment check runs ONCE, before the SHA/tag split, and
* applies to BOTH branches -- it used to run only inside the SHA branch,
* so a stale `# vX.Y.Z` note beside a genuinely correct TAG reference
* (`uses: ...@v1.6.1 # v1.5.0`) passed silently. A stale annotation
* beside a correct pin is exactly the "docs say one thing, the pin says
* another" confusion this checker exists to end.
*
* The comment is read from the whole line, so on a line carrying two
* references it applies to both. Deliberate: a stale version note on a
* documentation line is a defect whichever reference it sits beside, and
* a `uses:` line in a workflow never carries two.
*/
function classifyRef(ref, release) {
const token = ref.ref;
const commentMatch = ref.lineText.match(TRAILING_COMMENT_RE);
const commentOk = !commentMatch || commentMatch[1] === release.version;
const commentSuffix = commentMatch
? (commentOk
? `, trailing # v${commentMatch[1]} agrees`
: `, but its trailing comment says v${commentMatch[1]} while package.json says v${release.version}`)
: '';

if (SHA_RE.test(token)) {
if (token.toLowerCase() !== release.sha.toLowerCase()) {
return { ok: false, reason: `SHA ${token} does not match the release SHA ${release.sha} (${release.tag})` };
}
return commentOk
? { ok: true, reason: `OK (SHA${commentSuffix})` }
: { ok: false, reason: `SHA matches${commentSuffix}` };
}

const tagMatch = TAG_RE.exec(token);
if (tagMatch) {
// Compare the WHOLE token against release.tag, never only the
// captured X.Y.Z core: TAG_RE recognizes a pre-release/build suffix
// as part of the SHAPE (so it is reported with a precise message
// instead of falling into "unrecognized"), but `v1.6.1-beta.1` is a
// DIFFERENT git ref from `v1.6.1` and can point at a different
// commit -- comparing cores alone would silently treat them as the
// same pin, which is the exact class of mis-verdict this file
// exists to catch, just moved one level up. Caught by running this
// very case before shipping it, not reasoned about.
if (token !== release.tag) {
return { ok: false, reason: `tag ${token} does not match package.json's version ${release.tag}` };
}
return commentOk
? { ok: true, reason: `OK (tag ${token}${commentSuffix})` }
: { ok: false, reason: `tag matches${commentSuffix}` };
}

return { ok: false, reason: `unpinned/unrecognized reference "${token}" -- neither a 40-hex SHA nor a vX.Y.Z tag` };
}

function main() {
let release;
try {
release = readReleaseInfo();
} catch (err) {
// Per the file header's exit-code contract: a broken precondition
// is exit 2, never the raw stack trace an unhandled throw would
// otherwise print at exit 1 -- exit 1 is reserved for "a pin
// drifted", and this is a different failure a maintainer must be
// able to tell apart from that at a glance.
console.error(`check-pins: cannot determine the release to check against -- ${err.message}`);
process.exitCode = 2;
return;
}

const refs = findReferences();

console.log(`check-pins: package.json version ${release.version} -> ${release.tag} = ${release.sha}`);

if (refs.length === 0) {
// Exit 3 ("the tool could not run and produced no verdict"), not 0:
// this checker's whole job is "did every FOUND reference check out",
// and with none found there is nothing to verdict on -- a green 0/0
// here is a vacuous pass, not evidence of anything pinned correctly.
console.log(`check-pins: no references to ${REF_PREFIX}@<ref> found in the tracked tree -- nothing was verified`);
process.exitCode = 3;
return;
}

let failures = 0;
for (const ref of refs) {
const verdict = classifyRef(ref, release);
console.log(`${ref.file}:${ref.line} ${verdict.ok ? 'OK' : 'MISMATCH'} -- ${verdict.reason}`);
if (!verdict.ok) failures++;
}

console.log(`check-pins: ${refs.length - failures}/${refs.length} references OK`);
process.exitCode = failures === 0 ? 0 : 1;
}

main();
Loading