Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/review-version-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"review": patch
---

Keep the pinned `ref:` inside review.md in sync with the released version. The release flow's version command now runs `utils/sync-review-version.ts` after `changeset version`, rewriting every `review-v<semver>` literal in review.md to the version being released so the bump lands in the same Version Packages commit that gets tagged; `workflows/review/version-sync.test.ts` fails CI whenever the ref and the `review` package version diverge. Also bumps the currently stale ref from review-v1.2.2 to review-v1.4.0 (v1.3.0 through v1.4.0 shipped with the lagging ref, so an un-overridden consumer checked out a lib without `lib/provenance.ts`).
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ jobs:
if: steps.has-changesets.outputs.result == 'true'
uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1
with:
# Not the default `changeset version`: version-packages also syncs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Not the default `changeset version`: version-packages also syncs
# Note the default `changeset version`: version-packages also syncs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded this comment as part of making the sync generic; it now leads with what version-packages does rather than the "Not the default" phrasing.

# the pinned `ref:` inside workflows/review/review.md to the version
# being released, so the bump lands in the same Version Packages
# commit that gets tagged (see utils/sync-review-version.ts).
version: pnpm run version-packages
publish: exit 1
env:
GITHUB_TOKEN: ${{ secrets.KHAN_ACTIONS_BOT_TOKEN }}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"repository": "git@github.com:Khan/actions.git",
"private": true,
"scripts": {
"version-packages": "changeset version && node -r @swc-node/register utils/sync-review-version.ts",
"lint": "eslint --ext .js,.mjs,.ts .",
"fix": "pnpm run lint --fix",
"test": "vitest",
Expand Down
133 changes: 133 additions & 0 deletions utils/sync-review-version-lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {describe, expect, it, vi} from "vitest";
import {
syncReviewVersion,
syncReviewVersionContent,
} from "./sync-review-version-lib.ts";

describe("syncReviewVersionContent", () => {
it("rewrites the pinned checkout ref to the given version", () => {
// Arrange: a stale ref, as shipped in review-v1.3.0 through v1.4.0.
const content = [
"pre-agent-steps:",
" - uses: actions/checkout@abc # v5",
" with:",
" repository: Khan/actions",
" ref: review-v1.2.2",
].join("\n");

// Act
const result = syncReviewVersionContent(content, "9.9.9");

// Assert
expect(result.replaced).toBe(1);
expect(result.content).toContain("ref: review-v9.9.9");
expect(result.content).not.toContain("review-v1.2.2");
});

it("rewrites every semver literal but leaves templates alone", () => {
// Arrange
const content =
"ref: review-v1.2.2\n" +
"the marker is `v=review-v<version>` (a template)\n" +
"released as review-v1.3.0\n";

// Act
const result = syncReviewVersionContent(content, "2.0.0");

// Assert
expect(result.replaced).toBe(2);
expect(result.content).toBe(
"ref: review-v2.0.0\n" +
"the marker is `v=review-v<version>` (a template)\n" +
"released as review-v2.0.0\n",
);
});

it("returns unchanged content when already at the version", () => {
// Act
const result = syncReviewVersionContent("ref: review-v1.4.0", "1.4.0");

// Assert
expect(result.replaced).toBe(1);
expect(result.content).toBe("ref: review-v1.4.0");
});
});

describe("syncReviewVersion", () => {
const makeDeps = (files: Record<string, string>) => {
const writes: Record<string, string> = {};
return {
writes,
deps: {
readFileSyncImpl: ((p: string) => {
const content = files[p];
if (content === undefined) {
throw new Error(`unexpected read: ${p}`);
}
return content;
}) as never,
writeFileSyncImpl: ((p: string, data: string) => {
writes[p] = data;
}) as never,
log: vi.fn(),
},
};
};

it("writes review.md with the package version substituted", () => {
// Arrange
const {writes, deps} = makeDeps({
"workflows/review/package.json":
'{"name": "review", "version": "1.5.0"}',
"workflows/review/review.md": " ref: review-v1.2.2\n",
});

// Act
syncReviewVersion(deps);

// Assert
expect(writes["workflows/review/review.md"]).toBe(
" ref: review-v1.5.0\n",
);
});

it("does not write when the ref is already current", () => {
// Arrange
const {writes, deps} = makeDeps({
"workflows/review/package.json":
'{"name": "review", "version": "1.5.0"}',
"workflows/review/review.md": " ref: review-v1.5.0\n",
});

// Act
syncReviewVersion(deps);

// Assert
expect(writes).toEqual({});
});

it("throws when review.md has no review-v literal to sync", () => {
// Arrange
const {deps} = makeDeps({
"workflows/review/package.json":
'{"name": "review", "version": "1.5.0"}',
"workflows/review/review.md": "no pinned ref here\n",
});

// Act + Assert
expect(() => syncReviewVersion(deps)).toThrow(
/No review-v<semver> literals/,
);
});

it("throws when package.json has no version", () => {
// Arrange
const {deps} = makeDeps({
"workflows/review/package.json": '{"name": "review"}',
"workflows/review/review.md": "ref: review-v1.2.2\n",
});

// Act + Assert
expect(() => syncReviewVersion(deps)).toThrow(/No version found/);
});
});
79 changes: 79 additions & 0 deletions utils/sync-review-version-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as fs from "fs";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this generic so that it updates any workflow? We're bound to have more shared workflows so we might as well handle this in a single way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3eafaab: the script is now utils/sync-workflow-versions.ts. It discovers every directory under workflows/ (the tag is named after the directory, matching publishWorkflow in utils/publish.ts), reads its package.json version, and rewrites every <name>-v<semver> literal in that workflow's markdown files, skipping CHANGELOG.md since changesets writes historic versions there on purpose. A workflow whose markdown embeds no literal is a no-op; a workflow that must embed one (like review.md's checkout ref) keeps its own contract test as the CI backstop.


/**
* Keep the released-version literals inside workflows/review/review.md in
* sync with the "review" package version.
*
* review.md checks out Khan/actions at a pinned `ref: review-v<version>` tag
* to fetch the deterministic lib the prompt invokes at runtime; that ref is
* supposed to name the release the file ships in. Changesets bumps
* package.json but knows nothing about prose in a markdown file, so this
* script runs alongside `changeset version` (see the root package.json
* "version-packages" script, used by .github/workflows/release.yml) to
* rewrite every review-v<semver> literal in review.md to the version being
* released. That way the bump lands in the same Version Packages commit that
* gets tagged, and the tag's review.md pins its own release.
*
* workflows/review/version-sync.test.ts is the CI backstop: it fails when
* the literals and the package version diverge.
*/

export const REVIEW_MD_PATH = "workflows/review/review.md";
export const REVIEW_PKG_PATH = "workflows/review/package.json";

export const REVIEW_VERSION_RE = /review-v\d+\.\d+\.\d+/g;

export const syncReviewVersionContent = (
content: string,
version: string,
): {content: string; replaced: number} => {
let replaced = 0;
const next = content.replace(REVIEW_VERSION_RE, () => {
replaced += 1;
return `review-v${version}`;
});
return {content: next, replaced};
};

export type SyncReviewVersionDeps = {
readFileSyncImpl?: typeof fs.readFileSync;
writeFileSyncImpl?: typeof fs.writeFileSync;
log?: (message: string) => void;
};

export const syncReviewVersion = (deps: SyncReviewVersionDeps = {}): void => {
const readFileSyncImpl = deps.readFileSyncImpl ?? fs.readFileSync;
const writeFileSyncImpl = deps.writeFileSyncImpl ?? fs.writeFileSync;
const log = deps.log ?? console.log;

const pkg = JSON.parse(readFileSyncImpl(REVIEW_PKG_PATH, "utf-8"));
if (!pkg.version) {
throw new Error(`No version found in ${REVIEW_PKG_PATH}`);
}

const content = readFileSyncImpl(REVIEW_MD_PATH, "utf-8");
const {content: synced, replaced} = syncReviewVersionContent(
content,
pkg.version,
);

if (replaced === 0) {
// At minimum the pinned-checkout `ref:` line must match; finding
// nothing means the file was restructured and the sync went blind.
throw new Error(
`No review-v<semver> literals found in ${REVIEW_MD_PATH}; ` +
`expected at least the pinned checkout ref line.`,
);
}

if (synced === content) {
log(`${REVIEW_MD_PATH}: already at review-v${pkg.version}`);
return;
}

writeFileSyncImpl(REVIEW_MD_PATH, synced);
log(
`${REVIEW_MD_PATH}: synced ${replaced} review-v literal(s) ` +
`to review-v${pkg.version}`,
);
};
13 changes: 13 additions & 0 deletions utils/sync-review-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Rewrite the review-v<version> literals in workflows/review/review.md (the
* pinned Khan/actions checkout ref) to the current "review" package version.
*
* Runs as part of the changesets version command (root package.json
* "version-packages", wired into .github/workflows/release.yml) so the bump
* lands in the same Version Packages commit that gets tagged.
*
* Usage: node -r @swc-node/register utils/sync-review-version.ts
*/
import {syncReviewVersion} from "./sync-review-version-lib.ts";

syncReviewVersion();
19 changes: 17 additions & 2 deletions workflows/review/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,21 @@ ceiling with everything spent and nothing posted.
gh aw add Khan/actions/workflows/review/review.md

# Or pin to a published version (recommended for stability):
gh aw add Khan/actions/workflows/review/review.md@review-v0
gh aw add Khan/actions/workflows/review/review.md@review-v0.1.0
gh aw add Khan/actions/workflows/review/review.md@review-v<major>
gh aw add Khan/actions/workflows/review/review.md@review-v<major>.<minor>.<patch>
```

This copies `review.md` into the consuming repo's `.github/workflows/`, records a
`source:` field pointing back here, and compiles `review.lock.yml`. Commit both,
plus the consumer config files below. Pull future updates with `gh aw update`
(a 3-way merge that preserves your local edits).

The tag is self-consistent: the `review.md` inside each `review-v<version>` tag
pins its own `pre-agent-steps` checkout `ref:` to that same version (the release
flow rewrites it; see [Versioning](#versioning)), so after `gh aw add` or
`gh aw update` the imported file already fetches the matching lib code and needs
no manual fix-up of the ref.

## Consumer configuration

The workflow imports the following files **from the consuming repo** (they resolve
Expand Down Expand Up @@ -213,6 +219,15 @@ tag) is cut **at the real commit tree** (not the rewritten-subtree bare tags tha
the `actions/` packages use), so the nested `workflows/review/review.md@<ref>` path
resolves for `gh aw add`.

The pinned checkout `ref:` inside `review.md` is part of the release: the version
command (`pnpm run version-packages`, wired into `release.yml`) runs
`utils/sync-review-version.ts` after `changeset version`, rewriting every
`review-v<semver>` literal in `review.md` to the version being released, so the
bump lands in the same Version Packages commit that gets tagged.
`version-sync.test.ts` here is the CI backstop: it fails any PR where those
literals do not match the `review` package version (releases v1.3.0 through
v1.4.0 shipped still pointing at v1.2.2, before the sync existed).

### Version attribution

Semver is the behavior contract: a release that changes the reviewer's behavior bumps
Expand Down
14 changes: 9 additions & 5 deletions workflows/review/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,16 +196,20 @@ models:
# repo, so the job fetches the code itself: check out Khan/actions at the pinned
# release below. The `ref` is the single version
# surface for prompt + code: it names the Khan/actions release this file ships in
# (changesets tag, `review-v<version>`), and any release that changes the prompt or
# the lib bumps it. Steps that run lib scripts invoke them from `gh-aw-review-lib/`
# via `npx -y tsx <script>`; npx fetches the runner on first use, so the checkout
# needs no install step.
# (changesets tag, `review-v<version>`). The bump is automated, not manual: the
# release flow's version step (utils/sync-review-version.ts, run alongside
# `changeset version` by release.yml) rewrites this ref to the version being
# released, in the same Version Packages commit that gets tagged, and
# workflows/review/version-sync.test.ts fails CI if the ref ever diverges from
# the `review` package version. Steps that run lib scripts invoke them from
# `gh-aw-review-lib/` via `npx -y tsx <script>`; npx fetches the runner on first
# use, so the checkout needs no install step.
pre-agent-steps:
- name: Check out shared review lib (Khan/actions)
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
repository: Khan/actions
ref: review-v1.2.2
ref: review-v1.4.0
path: gh-aw-review-lib
persist-credentials: false

Expand Down
37 changes: 37 additions & 0 deletions workflows/review/version-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* CI backstop for the review.md version surface.
*
* review.md checks out Khan/actions at a pinned `ref: review-v<version>` tag
* to fetch the lib code the prompt invokes at runtime; that ref must name the
* release the file ships in, or consumers get a prompt from one version
* running code from another (review-v1.3.0 through v1.4.0 shipped this way,
* still pointing at v1.2.2). The release flow keeps the ref true by running
* utils/sync-review-version.ts alongside `changeset version`; this test fails
* any PR (the Version Packages PR included) where the literals in review.md
* do not match the "review" package version.
*/
import * as fs from "fs";
import {describe, expect, it} from "vitest";

const reviewMd = fs.readFileSync(
new URL("./review.md", import.meta.url),
"utf-8",
);
const pkg = JSON.parse(
fs.readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
);

describe("review.md version surface", () => {
it("pins the Khan/actions checkout ref to this release's version", () => {
const refs = [...reviewMd.matchAll(/^\s*ref:\s*(\S+)\s*$/gm)].map(
(m) => m[1],
);
expect(refs).toEqual([`review-v${pkg.version}`]);
});

it("matches every review-v<semver> literal to the package version", () => {
const literals = reviewMd.match(/review-v\d+\.\d+\.\d+/g) ?? [];
expect(literals.length).toBeGreaterThan(0);
expect(new Set(literals)).toEqual(new Set([`review-v${pkg.version}`]));
});
});
Loading