From 686e1b21d4a0f31be6e8f28fac880ae819a44128 Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 00:47:07 -0700 Subject: [PATCH 1/9] test: the index publish job opens one pull request into master from ci/codex-index with FRAMERS_BUMP_TOKEN and pushes nothing to master, leaves that pull request to the run of a later change to the index's inputs, and the landing label follows the index pull request's Test Suite and rename-guard as it follows the bump's --- tests/bump-framers-deps.test.js | 85 +++++++++-- tests/index-pull-request.test.js | 240 +++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+), 12 deletions(-) create mode 100644 tests/index-pull-request.test.js diff --git a/tests/bump-framers-deps.test.js b/tests/bump-framers-deps.test.js index 1fabd09..87027e1 100644 --- a/tests/bump-framers-deps.test.js +++ b/tests/bump-framers-deps.test.js @@ -14,7 +14,9 @@ * The bump labels nothing. Its pull request gets the landing label from landing-follows-the-bump-checks.yml, once its * own Test Suite and rename-guard runs pass on its head. That workflow starts on workflow_run, which runs the file as it * reads on master, so GitHub runs it only after it merges; these tests run its script against a stand-in for the GitHub - * API that holds the pull request's head, base, labels, comments and runs. + * API that holds the pull request's head, base, labels, comments and runs. The same workflow labels the index pull request + * auto-index.yml opens from ci/codex-index (tests/index-pull-request.test.js reads that workflow); the cases at the end + * run its script for that branch. * * Run: npx vitest run tests/bump-framers-deps.test.js */ @@ -206,13 +208,15 @@ describe('the bump opens its pull request with FRAMERS_BUMP_TOKEN, so the pull r }); // landing-follows-the-bump-checks.yml, run here the way actions/github-script runs a script: as the body of an async -// function, against a stand-in for the GitHub API that holds the bump pull request's head, base, labels, comments and the -// pull request runs GitHub lists for a commit. +// function, against a stand-in for the GitHub API that holds a followed pull request's head, base, labels, comments and the +// pull request runs GitHub lists for a commit. It follows the bump's pull request and the index's. const LABEL_FLOW = '.github/workflows/landing-follows-the-bump-checks.yml'; const JOB = 'follow-the-guards'; const TEST_SUITE = '.github/workflows/test.yml'; const RENAME_GUARD = '.github/workflows/rename-guard.yml'; const BRANCH = 'chore/bump-framers-deps'; +const INDEX = '.github/workflows/auto-index.yml'; +const INDEX_BRANCH = 'ci/codex-index'; const OWNER = 'framerslab'; const REPO = 'codex'; const PR = 12; @@ -229,18 +233,25 @@ const labelScript = () => { if (!step) throw new Error(`${LABEL_FLOW}: job ${JOB} has no github-script step`); return step.with.script; }; +// The branch auto-index.yml opens the index pull request from. +const indexBranch = () => { + const steps = yaml.load(read(INDEX)).jobs['publish-artifacts']?.steps ?? []; + const step = steps.find((s) => String(s.uses).startsWith('peter-evans/create-pull-request@')); + if (!step) throw new Error(`${INDEX}: the publish job opens no pull request`); + return step.with.branch; +}; // A pull request run of a workflow on a commit, as GitHub lists it. GitHub's REST description gives a run's path as // path@ref (".github/workflows/build.yml@main"), so a run can carry either form. const prRun = ( workflowPath, - { id, sha = NEW, status = 'completed', conclusion = 'success', from = `${OWNER}/${REPO}`, ref = null } + { id, sha = NEW, status = 'completed', conclusion = 'success', from = `${OWNER}/${REPO}`, ref = null, branch = BRANCH } ) => ({ id, run_number: id, path: ref ? `${workflowPath}@${ref}` : workflowPath, head_sha: sha, - head_branch: BRANCH, + head_branch: branch, head_repository: { full_name: from }, event: 'pull_request', status, @@ -259,6 +270,7 @@ const quoting = { // such as a push, an edit of the base or a guard finishing. const fakeGitHub = ({ head = NEW, + branch = BRANCH, base = 'master', open = true, labels = [], @@ -285,8 +297,8 @@ const fakeGitHub = ({ rest: { pulls: { list: api('pulls.list', (params) => - open && params.state === 'open' && params.head === `${OWNER}:${BRANCH}` && params.base === state.base - ? [{ number: PR, head: { sha: state.head, ref: BRANCH }, base: { ref: state.base } }] + open && params.state === 'open' && params.head === `${OWNER}:${branch}` && params.base === state.base + ? [{ number: PR, head: { sha: state.head, ref: branch }, base: { ref: state.base } }] : [] ), get: api('pulls.get', () => ({ number: PR, head: { sha: state.head }, base: { ref: state.base } })), @@ -335,34 +347,36 @@ const fakeGitHub = ({ }; // The workflow_run event of a guard's pull request run. -const guardEvent = (action, sha = NEW) => ({ +const guardEvent = (action, sha = NEW, branch = BRANCH) => ({ repo: { owner: OWNER, repo: REPO }, payload: { action, - workflow_run: { head_sha: sha, head_branch: BRANCH, event: 'pull_request', head_repository: { full_name: `${OWNER}/${REPO}` } }, + workflow_run: { head_sha: sha, head_branch: branch, event: 'pull_request', head_repository: { full_name: `${OWNER}/${REPO}` } }, }, }); const follow = (github, context) => new AsyncFunction('github', 'context', 'core', labelScript())(github, context, { info() {}, warning() {} }); describe('the bump pull request carries the landing label only on a head whose Test Suite and rename-guard passed', () => { - it('starts when Test Suite or rename-guard is requested, starts or completes on the branch the bump pushes', () => { + it('starts when Test Suite or rename-guard is requested, starts or completes on the branch the bump or the index pushes', () => { const { on } = labelFlow(); expect(Object.keys(on)).toEqual(['workflow_run']); // A re-run sends no requested event, only in_progress and completed. expect(on.workflow_run.types).toEqual(['requested', 'in_progress', 'completed']); - expect(on.workflow_run.branches).toEqual([stepUsing('peter-evans/create-pull-request').with.branch]); + expect(indexBranch()).toBe(INDEX_BRANCH); + expect(on.workflow_run.branches).toEqual([stepUsing('peter-evans/create-pull-request').with.branch, indexBranch()]); // workflow_run names the workflows it follows by their names, so they stay the names the two files carry. expect(on.workflow_run.workflows).toEqual([yaml.load(read(TEST_SUITE)).name, yaml.load(read(RENAME_GUARD)).name]); }); - it("runs only for a pull request from this repository's own bump branch, checks out no code and pastes nothing into its script", () => { + it("runs only for a pull request from this repository's own bump or index branch, checks out no code and pastes nothing into its script", () => { const { permissions, concurrency, jobs } = labelFlow(); expect(permissions).toEqual({ contents: 'read' }); expect(Object.keys(jobs)).toEqual([JOB]); const job = jobs[JOB]; expect(job.if).toContain("github.event.workflow_run.event == 'pull_request'"); expect(job.if).toContain(`github.event.workflow_run.head_branch == '${BRANCH}'`); + expect(job.if).toContain(`github.event.workflow_run.head_branch == '${INDEX_BRANCH}'`); expect(job.if).toContain('github.event.workflow_run.head_repository.full_name == github.repository'); expect(job.permissions).toEqual({ actions: 'read', issues: 'write', 'pull-requests': 'write' }); expect(job.steps.some((step) => String(step.uses).startsWith('actions/checkout@'))).toBe(false); @@ -524,3 +538,50 @@ describe('the bump pull request carries the landing label only on a head whose T expect(state.labels.has('landing')).toBe(false); }); }); + +// auto-index.yml opens the index pull request from ci/codex-index with FRAMERS_BUMP_TOKEN, as the bump opens its own, and +// labels nothing; the same workflow keeps its landing label true to its guards. +describe('the index pull request carries the landing label only on a head whose Test Suite and rename-guard passed', () => { + const indexRuns = (overrides = {}) => [ + prRun(TEST_SUITE, { id: 201, branch: INDEX_BRANCH, ...overrides }), + prRun(RENAME_GUARD, { id: 202, branch: INDEX_BRANCH }), + ]; + + it('labels the index pull request once both guards passed on its head, and names both runs', async () => { + const { github, state } = fakeGitHub({ branch: INDEX_BRANCH, runs: indexRuns() }); + await follow(github, guardEvent('completed', NEW, INDEX_BRANCH)); + expect([...state.labels]).toEqual(['landing']); + expect(state.comments).toHaveLength(1); + expect(state.comments[0].body.startsWith(MARKER)).toBe(true); + expect(state.comments[0].body).toContain('Guards passed on bbbbbbb'); + expect(state.comments[0].body).toContain('Test Suite, run 201'); + expect(state.comments[0].body).toContain('rename-guard, run 202'); + }); + + it('takes the label off the index pull request where a guard ended without passing, and says which', async () => { + const runs = indexRuns({ conclusion: 'failure' }); + const { github, state } = fakeGitHub({ branch: INDEX_BRANCH, labels: ['landing'], comments: [guardsComment(OLD)], runs }); + await follow(github, guardEvent('completed', NEW, INDEX_BRANCH)); + expect(state.labels.has('landing')).toBe(false); + expect(state.comments).toHaveLength(1); + expect(state.comments[0].body).toContain('Test Suite (failure, run 201) did not pass on bbbbbbb'); + }); + + it("reads only the index branch's runs for the index pull request", async () => { + // The bump branch's runs on the same commit say nothing about the index pull request. + const runs = [prRun(TEST_SUITE, { id: 101 }), prRun(RENAME_GUARD, { id: 102 })]; + const { github, state, calls } = fakeGitHub({ branch: INDEX_BRANCH, runs }); + await follow(github, guardEvent('completed', NEW, INDEX_BRANCH)); + expect(calls).not.toContain('issues.addLabels'); + expect(state.labels.has('landing')).toBe(false); + }); + + it('follows no branch but the two, whichever branch a run comes from', async () => { + const elsewhere = 'feature/elsewhere'; + const runs = [prRun(TEST_SUITE, { id: 301, branch: elsewhere }), prRun(RENAME_GUARD, { id: 302, branch: elsewhere })]; + const { github, state, calls } = fakeGitHub({ branch: elsewhere, labels: ['landing'], runs }); + await follow(github, guardEvent('completed', NEW, elsewhere)); + expect(calls).toEqual([]); + expect([...state.labels]).toEqual(['landing']); + }); +}); diff --git a/tests/index-pull-request.test.js b/tests/index-pull-request.test.js new file mode 100644 index 0000000..33d1fcd --- /dev/null +++ b/tests/index-pull-request.test.js @@ -0,0 +1,240 @@ +/** + * The search index reaches master through a pull request. + * + * master is protected: its required checks must pass before a change lands there, administrators included, and a commit + * can be pushed to it only once they have passed ("About protected branches", Require status checks before merging: + * https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging). + * A commit a job makes has no checks, so auto-index.yml's publish job no longer pushes the index to master: it opens or + * updates one pull request into master from ci/codex-index carrying the four files its build job archived. It pushes and + * opens that pull request with FRAMERS_BUMP_TOKEN, as the weekly bump does, so the pull request's own events start Test + * Suite and rename-guard (tests/bump-framers-deps.test.js quotes GitHub's rule), and landing-follows-the-bump-checks.yml + * labels it landing once both pass on its head. + * + * The publish job runs only on master, so GitHub reports a broken publish job only after a merge. These tests parse the + * workflow, run its first step's shell with the secret empty and set, run its ordering step in scratch repositories, and + * read the token, the permissions, the commit message and the paths it commits. + * + * Run: npx vitest run tests/index-pull-request.test.js + */ + +import { describe, it, expect } from 'vitest'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const yaml = require('js-yaml'); +const { execFileSync, spawnSync } = require('child_process'); + +const INDEX = '.github/workflows/auto-index.yml'; +const BUMP = '.github/workflows/bump-framers-deps.yml'; +const read = (rel) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); +const text = read(INDEX); +const workflow = yaml.load(text); +const build = workflow.jobs['build-index']; +const publish = workflow.jobs['publish-artifacts']; +const stepUsing = (steps, action) => steps.find((step) => String(step.uses).startsWith(`${action}@`)); +const create = () => stepUsing(publish.steps, 'peter-evans/create-pull-request'); +const order = () => publish.steps.find((step) => step.id === 'order'); +// An action input that lists paths, one a line (create-pull-request also splits on commas). +const listed = (value) => + String(value ?? '') + .split(/[\n,]+/) + .map((entry) => entry.trim()) + .filter(Boolean); + +describe('the index publish job opens a pull request into master, and pushes nothing to master', () => { + it('is valid YAML whose every run block passes bash -n', () => { + const blocks = [...build.steps, ...publish.steps] + .filter((step) => step.run) + .map((step) => step.run.replace(/\$\{\{[^}]*\}\}/g, 'X')); + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + expect(() => execFileSync('bash', ['-n'], { input: block, stdio: ['pipe', 'pipe', 'pipe'] })).not.toThrow(); + } + }); + + it('runs no git push or git commit of its own', () => { + const runs = Object.values(workflow.jobs) + .flatMap((job) => job.steps) + .filter((step) => step.run); + for (const step of runs) expect(step.run, step.name).not.toMatch(/\bgit\s+(push|commit)\b/); + }); + + it('opens or updates one pull request into master from ci/codex-index with FRAMERS_BUMP_TOKEN, and labels nothing itself', () => { + const step = create(); + expect(step, 'a create-pull-request step in the publish job').toBeDefined(); + expect(step.with.token).toBe('${{ secrets.FRAMERS_BUMP_TOKEN }}'); + // branch-token defaults to token, so the branch is pushed with the same token. + expect(step.with).not.toHaveProperty('branch-token'); + expect(step.with.base).toBe('master'); + expect(step.with.branch).toBe('ci/codex-index'); + expect(step.with['delete-branch']).toBe(true); + // The label waits for the pull request's own checks: landing-follows-the-bump-checks.yml adds it once they pass. + expect(step.with).not.toHaveProperty('labels'); + }); + + it('pins create-pull-request to the commit the weekly bump pins, with the release it carries beside it', () => { + const bump = yaml.load(read(BUMP)); + const uses = create()?.uses; + expect(uses).toBe(stepUsing(bump.jobs.bump.steps, 'peter-evans/create-pull-request').uses); + expect(uses).toMatch(/^peter-evans\/create-pull-request@[0-9a-f]{40}$/); + const line = text.split('\n').find((each) => each.includes(`uses: ${uses}`)); + expect(line).toMatch(/\s# v\d+\.\d+\.\d+$/); + }); + + it("keeps both jobs' GITHUB_TOKEN read-only, so nothing can push to master or open a pull request with it", () => { + expect(workflow.permissions).toEqual({ contents: 'read' }); + for (const [id, job] of Object.entries(workflow.jobs)) expect(job.permissions, id).toBeUndefined(); + }); + + it('stops at its first step, with an error annotation, when the secret is empty', () => { + const [first] = publish.steps; + expect(first.env).toEqual({ FRAMERS_BUMP_TOKEN: '${{ secrets.FRAMERS_BUMP_TOKEN }}' }); + // The runner runs a step's script with bash -e. + const check = (value) => + spawnSync('bash', ['-e', '-c', first.run], { + env: { PATH: process.env.PATH, FRAMERS_BUMP_TOKEN: value }, + encoding: 'utf8', + }); + const missing = check(''); + expect(missing.status).toBe(1); + expect(missing.stdout).toMatch(/^::error::FRAMERS_BUMP_TOKEN /m); + expect(check('a-token').status).toBe(0); + }); + + it('publishes from master only, the branch its pull request goes into', () => { + expect(publish.if).toBe("github.ref == 'refs/heads/master'"); + }); + + // GitHub skips the push and pull_request workflows of a commit whose message carries one of these, so a pull request + // whose head commit carried one would get no Test Suite or rename-guard, and its required checks would never report. + it('commits with a message that skips no workflow, so the pull request gets its required checks', () => { + const inputs = create()?.with ?? {}; + expect(inputs['commit-message']).toBeTruthy(); + for (const value of [inputs['commit-message'], inputs.title]) { + expect(value).not.toMatch(/\[(skip ci|ci skip|no ci|skip actions|actions skip)\]/i); + expect(value).not.toMatch(/skip-checks:\s*true/i); + } + }); + + // create-pull-request stages add-paths with one git add, which stages nothing when one path matches no file; each of + // the four is tracked, so it is in the checkout even when the build archived no new copy of it. + it('commits exactly the files the build job archives, each tracked here', () => { + const upload = stepUsing(build.steps, 'actions/upload-artifact'); + const download = stepUsing(publish.steps, 'actions/download-artifact'); + expect(download.with.name).toBe(upload.with.name); + const archived = listed(upload.with.path).sort(); + expect(archived).toEqual(['codex-blocks.json', 'codex-index.json', 'codex-report.json', 'codex-search.json']); + expect(listed(create()?.with['add-paths']).sort()).toEqual(archived); + const tracked = execFileSync('git', ['ls-files', '--', ...archived], { cwd: path.join(__dirname, '..'), encoding: 'utf8' }) + .split('\n') + .filter(Boolean) + .sort(); + expect(tracked).toEqual(archived); + }); + + it('publishes one run at a time and never cancels one that started, so two runs never push the branch at once', () => { + expect(publish.concurrency?.group).toBeTruthy(); + expect(publish.concurrency?.['cancel-in-progress']).toBe(false); + }); +}); + +describe("a run leaves the index pull request to the run of a later change to the index's inputs", () => { + it('compares the paths whose change starts this workflow, and gates the download and the pull request on that', () => { + const step = order(); + expect(step, 'a step with id order').toBeDefined(); + for (const entry of workflow.on.push.paths) expect(step.run).toContain(entry.replace(/\/\*\*$/, '')); + const download = stepUsing(publish.steps, 'actions/download-artifact'); + expect(download.if).toBe("steps.order.outputs.later == 'false'"); + expect(create().if).toBe("steps.order.outputs.later == 'false'"); + // It reads the checkout, and runs before the download and the pull request it gates. + const at = (each) => publish.steps.indexOf(each); + expect(at(stepUsing(publish.steps, 'actions/checkout'))).toBeLessThan(at(step)); + expect(at(step)).toBeLessThan(at(download)); + expect(at(download)).toBeLessThan(at(create())); + }); + + // origin holds master. The run's checkout holds the run's commit at depth 1, as actions/checkout leaves it; then master + // takes the change (or none), and the step runs in the checkout with the run's commit as GITHUB_SHA. + const runOrder = (change) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'index-order-')); + const git = (cwd, ...args) => { + const res = spawnSync( + 'git', + ['-c', 'user.name=index', '-c', 'user.email=index@example.invalid', '-c', 'commit.gpgsign=false', ...args], + { cwd, encoding: 'utf8' } + ); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); + return res.stdout.trim(); + }; + const write = (file, content) => { + fs.mkdirSync(path.dirname(path.join(root, 'work', file)), { recursive: true }); + fs.writeFileSync(path.join(root, 'work', file), content); + }; + const land = (message, files) => { + for (const [file, content] of Object.entries(files)) write(file, content); + git(path.join(root, 'work'), 'add', '--all'); + git(path.join(root, 'work'), 'commit', '--quiet', '-m', message); + git(path.join(root, 'work'), 'push', '--quiet', 'origin', 'master'); + }; + try { + const origin = path.join(root, 'origin.git'); + git(root, 'init', '--quiet', '--bare', origin); + fs.mkdirSync(path.join(root, 'work')); + git(path.join(root, 'work'), 'init', '--quiet'); + git(path.join(root, 'work'), 'symbolic-ref', 'HEAD', 'refs/heads/master'); + git(path.join(root, 'work'), 'remote', 'add', 'origin', `file://${origin}`); + land('the run commit', { + 'weaves/frame/strand.md': 'a strand\n', + 'scripts/auto-index.js': 'an indexer\n', + 'schema/strand.schema.yaml': 'a schema\n', + '.github/workflows/auto-index.yml': 'this workflow\n', + 'README.md': 'a readme\n', + 'codex-index.json': '[]\n', + }); + const sha = git(path.join(root, 'work'), 'rev-parse', 'HEAD'); + git(root, 'clone', '--quiet', '--depth=1', '--branch', 'master', `file://${origin}`, 'checkout'); + change(land); + const output = path.join(root, 'output'); + fs.writeFileSync(output, ''); + const res = spawnSync('bash', ['-e', '-c', order().run], { + cwd: path.join(root, 'checkout'), + env: { PATH: process.env.PATH, HOME: root, GITHUB_SHA: sha, GITHUB_OUTPUT: output }, + encoding: 'utf8', + }); + expect(res.status, res.stdout + res.stderr).toBe(0); + return { output: fs.readFileSync(output, 'utf8'), stdout: res.stdout }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }; + + it.each([ + ['a strand under weaves/', 'weaves/frame/strand.md'], + ['a script under scripts/', 'scripts/auto-index.js'], + ['a schema under schema/', 'schema/strand.schema.yaml'], + ['this workflow', '.github/workflows/auto-index.yml'], + ])("leaves the pull request when master changed %s after the run's commit", (_what, file) => { + const { output, stdout } = runOrder((land) => land('a later change', { [file]: 'changed\n' })); + expect(output).toContain('later=true'); + expect(output).not.toContain('later=false'); + expect(stdout).toMatch(/^::notice::/m); + }); + + it.each([ + ["master is still at the run's commit", () => {}], + ['master changed only the README', (land) => land('docs', { 'README.md': 'changed\n' })], + ['master took an index pull request', (land) => land('the index', { 'codex-index.json': '[1]\n' })], + [ + 'master changed a strand and changed it back', + (land) => { + land('a change', { 'weaves/frame/strand.md': 'changed\n' }); + land('the change undone', { 'weaves/frame/strand.md': 'a strand\n' }); + }, + ], + ])('publishes when %s', (_what, change) => { + const { output } = runOrder(change); + expect(output).toContain('later=false'); + expect(output).not.toContain('later=true'); + }); +}); From 9f7ea4596c149822aedf106a3ecdef7c5879ad04 Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 00:48:39 -0700 Subject: [PATCH 2/9] ci: the index publish job opens one pull request into master from ci/codex-index with FRAMERS_BUMP_TOKEN instead of pushing the index to master, which master's required checks refuse; it publishes from master only, one run at a time, leaves the pull request to the run of a later change to the index's inputs, and archives and downloads with upload-artifact v7 and download-artifact v8; the landing label follows that pull request's Test Suite and rename-guard as it follows the bump's --- .github/workflows/auto-index.yml | 93 ++++++++++++++++--- .../landing-follows-the-bump-checks.yml | 40 +++++--- 2 files changed, 103 insertions(+), 30 deletions(-) diff --git a/.github/workflows/auto-index.yml b/.github/workflows/auto-index.yml index ab8f204..d3303e2 100644 --- a/.github/workflows/auto-index.yml +++ b/.github/workflows/auto-index.yml @@ -1,5 +1,19 @@ name: Build and Publish Codex Index +# The build-index job builds codex-index.json, codex-search.json, codex-report.json and codex-blocks.json and archives +# them. On master, publish-artifacts then opens or updates one pull request into master from ci/codex-index carrying +# them. It never pushes to master: master is protected, and a commit reaches it only once its required checks have +# passed, which a commit made in a job has not ("About protected branches", Require status checks before merging: +# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging). +# +# The branch is pushed and the pull request opened with FRAMERS_BUMP_TOKEN, a personal token held as a framerslab +# organization secret, as bump-framers-deps.yml does: a pull request a workflow opens with GITHUB_TOKEN gets its +# pull_request runs in an approval-required state, with no job, so its required checks would never report. The commit +# message carries no skip marker, since GitHub starts none of the pull_request workflows of a head commit whose message +# says [skip ci]. Nothing here labels or merges the pull request: landing-follows-the-bump-checks.yml adds the landing +# label once its Test Suite and rename-guard pass on its head, and it lands after its review comments are read, answered +# and resolved. tests/index-pull-request.test.js reads this file. + on: push: paths: @@ -12,6 +26,10 @@ on: types: - frame-deploy-complete +# The pull request goes through FRAMERS_BUMP_TOKEN, so each job's own GITHUB_TOKEN only reads. +permissions: + contents: read + jobs: build-index: runs-on: ubuntu-latest @@ -47,7 +65,7 @@ jobs: run: pnpm run build:search - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: codex-search-assets path: | @@ -59,23 +77,68 @@ jobs: publish-artifacts: needs: build-index runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/master' + # One run at a time, so two runs never push the pull request branch at once; GitHub keeps the newest waiting run. + concurrency: + group: codex-index-pull-request + cancel-in-progress: false + # FRAMERS_BUMP_TOKEN is a personal token that reaches past this repository, and a step can change what a later step of + # its job runs, so this job installs and runs none of the repository's code, and its third-party action is pinned to a + # commit, with the release that commit carries named beside it. steps: + # First: without the secret, create-pull-request stops with "Input 'token' not supplied" only at its own step. + - name: FRAMERS_BUMP_TOKEN is available + env: + FRAMERS_BUMP_TOKEN: ${{ secrets.FRAMERS_BUMP_TOKEN }} + run: | + if [ -z "$FRAMERS_BUMP_TOKEN" ]; then + echo "::error::FRAMERS_BUMP_TOKEN is not available here; a pull request opened with GITHUB_TOKEN gets no CI job." + exit 1 + fi + - uses: actions/checkout@v5 - - - uses: actions/download-artifact@v4 - with: - name: codex-search-assets - path: . - - name: Commit and push JSON assets + # Each run waits for its own build, so a run of an earlier commit can get here after a later commit's run. The index + # is built from the paths whose change starts this workflow (on.push.paths). When master's head differs from this + # run's commit under them, a later push changed them and its run publishes, so this run leaves the pull request alone + # instead of putting an older index on it. + - name: Leave the index to the run of a later change + id: order run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add codex-index.json codex-search.json codex-report.json codex-blocks.json - if ! git diff --cached --quiet; then - git commit -m "ci(codex): update search index and block tags [skip ci]" - git push + git fetch --quiet --depth=1 origin master + if git diff --quiet "$GITHUB_SHA" FETCH_HEAD -- weaves scripts schema .github/workflows/auto-index.yml; then + echo "later=false" >> "$GITHUB_OUTPUT" else - echo "No changes to commit." + echo "::notice::master changed weaves/, scripts/, schema/ or this workflow after ${GITHUB_SHA::7}; the run of that change publishes the index." + echo "later=true" >> "$GITHUB_OUTPUT" fi + + - uses: actions/download-artifact@v8 + if: steps.order.outputs.later == 'false' + with: + name: codex-search-assets + path: . + + - name: Open or update the index pull request + if: steps.order.outputs.later == 'false' + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + with: + # the personal token, so the pull request's own events start Test Suite and rename-guard (see the file's top); + # branch-token defaults to it, so the branch is pushed with it too + token: ${{ secrets.FRAMERS_BUMP_TOKEN }} + commit-message: 'ci(codex): update search index and block tags' + title: 'ci(codex): update search index and block tags' + body: | + The search index files built from ${{ github.sha }} by run ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}: `codex-index.json`, `codex-search.json`, `codex-report.json` and `codex-blocks.json`. + + master is protected, so the index reaches it through this pull request. The Build and Publish Codex Index workflow opens and updates it with `FRAMERS_BUMP_TOKEN`, so its Test Suite and rename-guard run; `landing-follows-the-bump-checks.yml` labels it `landing` once both pass on its head, and it lands after its review comments are read, answered and resolved. A later run replaces this branch with its own build, and closes the pull request when the index no longer differs from master's. + branch: ci/codex-index + base: master + delete-branch: true + # The four files the build job archives, each tracked here. The action stages them with one git add -- , + # which stages nothing when one path matches no file; tests/index-pull-request.test.js checks the list. + add-paths: | + codex-index.json + codex-search.json + codex-report.json + codex-blocks.json diff --git a/.github/workflows/landing-follows-the-bump-checks.yml b/.github/workflows/landing-follows-the-bump-checks.yml index 7b2467f..5d519c9 100644 --- a/.github/workflows/landing-follows-the-bump-checks.yml +++ b/.github/workflows/landing-follows-the-bump-checks.yml @@ -1,10 +1,12 @@ -# The weekly bump's pull request carries the landing label only on a head whose guards passed. +# The weekly bump's pull request and the search index's pull request carry the landing label only on a head whose guards +# passed. # -# bump-framers-deps.yml opens one pull request into master from chore/bump-framers-deps and labels nothing. Its guards -# are that pull request's own Test Suite and rename-guard runs, which start because the bump pushes and opens it with -# FRAMERS_BUMP_TOKEN. This workflow starts each time one of them is requested, starts or completes on that branch (a -# re-run sends no requested event, only the other two), and decides from the runs as they stand, whichever event -# started it: it reads each guard's newest pull request run on the pull request's head. +# bump-framers-deps.yml opens one pull request into master from chore/bump-framers-deps, and auto-index.yml one from +# ci/codex-index; neither labels anything. A pull request's guards are its own Test Suite and rename-guard runs, which +# start because each workflow pushes and opens it with FRAMERS_BUMP_TOKEN. This workflow starts each time one of them is +# requested, starts or completes on either branch (a re-run sends no requested event, only the other two), takes the +# branch from the run, and decides from the runs as they stand, whichever event started it: it reads each guard's newest +# pull request run on that branch's pull request's head. # - Both passed: it adds the "landing" label (created on first use) and keeps the guards comment (github-actions[bot]'s # comment that starts with the marker) naming the two runs. When that comment cannot be # written, the label comes off again. @@ -19,17 +21,17 @@ # show on master's last commit. Reading again after each write keeps the last word with the latest state instead. # # workflow_run runs this file as it reads on master, the default branch, with a token that can write here, whichever -# pull request the guards ran for. So its job checks out no code, runs only for a pull request from this repository's -# own chore/bump-framers-deps branch (a fork's branch of the same name is skipped), and reads the run and the pull -# request from the event and the API inside its script, never as text pasted into it. Nothing here merges: the pull -# request lands after its review comments are read, answered and resolved. -name: Landing follows the bump checks +# pull request the guards ran for. So its job checks out no code, runs only for a pull request from one of this +# repository's own two branches (a fork's branch of the same name is skipped), and reads the run and the pull request +# from the event and the API inside its script, never as text pasted into it. Nothing here merges: the pull request lands +# after its review comments are read, answered and resolved. +name: Landing follows the bump and index checks on: workflow_run: workflows: ['Test Suite', 'rename-guard'] types: [requested, in_progress, completed] - branches: [chore/bump-framers-deps] + branches: [chore/bump-framers-deps, ci/codex-index] permissions: contents: read @@ -38,7 +40,8 @@ jobs: follow-the-guards: if: | github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.head_branch == 'chore/bump-framers-deps' && + (github.event.workflow_run.head_branch == 'chore/bump-framers-deps' || + github.event.workflow_run.head_branch == 'ci/codex-index') && github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-latest permissions: @@ -54,7 +57,14 @@ jobs: script: | const { owner, repo } = context.repo; const trigger = context.payload.workflow_run; - const BRANCH = 'chore/bump-framers-deps'; + // The pull requests this workflow follows, by the branch each is opened from: the weekly bump's and the search + // index's. The job runs only for these two; the script takes the branch from the run and checks it again. + const BRANCHES = ['chore/bump-framers-deps', 'ci/codex-index']; + const BRANCH = trigger.head_branch; + if (!BRANCHES.includes(BRANCH)) { + core.info(`${BRANCH} is not a branch this workflow follows.`); + return; + } const BASE = 'master'; // The pull request's guards, by the file each workflow runs from. Their runs are listed by that file, so the // form GitHub gives a run's path in (its REST description shows path@ref) plays no part. @@ -64,7 +74,7 @@ jobs: ]; const marker = ''; - // The bump's open pull request, from this repository's branch into master. + // The branch's open pull request, from this repository's branch into master. const openPull = async () => { const { data: pulls } = await github.rest.pulls.list({ owner, From a92ca3a9ecff607e4053145d151f241df95b4cc4 Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 00:51:46 -0700 Subject: [PATCH 3/9] docs: the index reaches master through a pull request from ci/codex-index, the landing label follows that pull request's guards, master's protection is described, and the guides commit a manual re-index or a tutorial change on a branch and open a pull request, since master refuses a direct push --- .github/WORKFLOWS.md | 30 +++++++++++++++++++++--------- README.md | 6 +++--- docs/BLOCK_TAGGING_TUTORIAL.md | 6 ++++-- docs/DEVELOPMENT.md | 7 +++++-- docs/RECATALOG_GUIDE.md | 6 ++++-- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index 270d54a..10434f7 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -51,15 +51,23 @@ The Codex repository uses several GitHub Actions workflows for automated content - Cache restoration for faster builds - Parallel job execution where possible -### 2. `auto-index.yml` +### 2. `auto-index.yml` (Build and Publish Codex Index) **Triggers:** -- Push to `weaves/`, `scripts/`, `schema/` +- Push to any branch that changes `weaves/`, `scripts/`, `schema/` or this workflow - Manual dispatch - Repository dispatch (frame-deploy-complete) **Purpose:** -Builds and publishes the search index JSON files. +Builds the search index files, `codex-index.json`, `codex-search.json`, `codex-report.json` and `codex-blocks.json`, and on `master` publishes them through one pull request: +- The build-index job builds the four files and archives them; on any other branch the run stops there +- On `master`, the publish-artifacts job opens or updates one pull request into `master` from `ci/codex-index` carrying them. It never pushes to `master`, which is protected (below): a commit made in a job has none of the required checks +- Pushes the branch and opens the pull request with `FRAMERS_BUMP_TOKEN`, as the weekly bump does, so the pull request gets Test Suite and rename-guard. Its commit message carries no skip marker: GitHub starts none of the `pull_request` workflows of a head commit whose message says `[skip ci]`, and the required checks would never report +- Stops at its first step, with an error, when `FRAMERS_BUMP_TOKEN` is not available; both jobs' own `GITHUB_TOKEN` only reads, and the publish job installs and runs none of the repository's code +- Publishes one run at a time. Each run waits for its own build, so a run of an earlier commit can reach the publish job after a later commit's run; when `master`'s head differs from the run's commit under `weaves/`, `scripts/`, `schema/` or this workflow, a later push changed them and its run publishes, so the earlier run leaves the pull request alone +- A later run replaces the branch with its own build, and closes the pull request when the index no longer differs from `master`'s +- Labels nothing: `landing-follows-the-bump-checks.yml` adds `landing` once the pull request's Test Suite and rename-guard pass on its head, and the pull request lands after its review comments are read, answered and resolved +- `tests/index-pull-request.test.js` reads the workflow, runs its first step, and runs its ordering step in scratch repositories, since GitHub runs the publish job only on `master` ### 3. `ai-enhance-pr.yml` @@ -163,13 +171,13 @@ Moves every `@framers/*` pin in a `package.json` to the newest version on npm, r - Labels nothing: `landing-follows-the-bump-checks.yml` adds `landing` once the pull request's Test Suite and rename-guard pass on its head, and the pull request lands after its review comments are read, answered and resolved - `tests/bump-framers-deps.test.js` reads the workflow, runs its first step and its npm lockfile refresh, and makes its commit in a scratch repository holding this repository's files, since GitHub reports a broken file only when the schedule or a dispatch starts it -### 10. `landing-follows-the-bump-checks.yml` (Landing follows the bump checks) +### 10. `landing-follows-the-bump-checks.yml` (Landing follows the bump and index checks) **Triggers:** -- A Test Suite or rename-guard run requested, started (`in_progress`) or completed on `chore/bump-framers-deps`; a re-run sends no requested event, only the other two +- A Test Suite or rename-guard run requested, started (`in_progress`) or completed on `chore/bump-framers-deps` or `ci/codex-index`; a re-run sends no requested event, only the other two **Purpose:** -Keeps the `landing` label on the weekly bump's pull request only on a head whose guards passed; its guards are that pull request's own Test Suite and rename-guard runs. Whichever event starts it, it reads each guard's newest pull request run on the pull request's head (listed by the guard's workflow file): +Keeps the `landing` label on the weekly bump's pull request and on the index's only on a head whose guards passed; a pull request's guards are its own Test Suite and rename-guard runs. It takes the branch from the run, and a run from any other branch changes nothing. Whichever event starts it, it reads each guard's newest pull request run on that branch's pull request's head (listed by the guard's workflow file): - Both passed: adds `landing` (created on first use) and keeps the guards comment (github-actions[bot]'s comment that starts with ``) naming the two runs; when that comment cannot be written, the label comes off again - Either ended without passing: takes the label off and says which in the guards comment - Either has no run yet or has not finished, as after a push, a reopen or a re-run: keeps the label off and says so in a guards comment already there @@ -177,11 +185,15 @@ Keeps the `landing` label on the weekly bump's pull request only on a head whose - A run for an older head changes nothing; a change of base is otherwise left to `landing-follows-the-base.yml` - No concurrency group: GitHub cancels a group's waiting run when a newer one queues, and a cancelled run would show on `master`'s last commit - `workflow_run` runs the file as it reads on `master` with a token that can write, so the job checks out no code, runs only for a pull request from this repository's own branch (a fork's branch of the same name is skipped) and reads the run and the pull request inside its script; it never merges -- `tests/bump-framers-deps.test.js` runs its script against a stand-in for the GitHub API, since GitHub runs the file only once it is on `master` +- `tests/bump-framers-deps.test.js` runs its script against a stand-in for the GitHub API, for both branches, since GitHub runs the file only once it is on `master` ### The `landing` label -No workflow here merges a pull request or turns on GitHub's auto-merge. A pull request is merged only after every review and comment on it has been read, fixed or answered and its threads resolved. Where a workflow's checks and guards pass, it adds the `landing` label (created on first use) and posts one comment saying which guards passed; when that comment cannot be posted, the label comes off again. A change of base takes the label off too, since the guards ran against the old base. On the weekly bump's pull request the guards are its own Test Suite and rename-guard runs: the label goes on once both pass on its head, and comes off when a push, a reopen or a re-run starts either again there or when either fails. A maintainer lands the pull request after that pass. +No workflow here merges a pull request or turns on GitHub's auto-merge. A pull request is merged only after every review and comment on it has been read, fixed or answered and its threads resolved. Where a workflow's checks and guards pass, it adds the `landing` label (created on first use) and posts one comment saying which guards passed; when that comment cannot be posted, the label comes off again. A change of base takes the label off too, since the guards ran against the old base. On the weekly bump's pull request and on the index's, the guards are its own Test Suite and rename-guard runs: the label goes on once both pass on its head, and comes off when a push, a reopen or a re-run starts either again there or when either fails. A maintainer lands the pull request after that pass. + +### `master`'s protection + +`master` is protected. A change reaches it only once Unit Tests, Vocabulary & NLP Tests, Cache Tests and Integration Tests (Test Suite) and guard (rename-guard) have passed on it, each accepted from GitHub Actions only; a pull request need not be up to date with `master` first. The rule binds administrators too, and force-pushes and deletion are blocked. Those five run on every pull request into `master`. A commit made in a job has none of them, so `master` refuses a job's push: Update Badges writes the `badges` branch, and the index and the weekly bump arrive as pull requests. Update Badges runs on `master` only and is not required. ## Caching Strategy @@ -216,7 +228,7 @@ restore-keys: | `ANTHROPIC_API_KEY` | AI only | Claude API key for AI enhancement | | `OPENAI_API_KEY` | AI only | OpenAI API key (fallback) | | `GH_PAT` | Yes | Personal access token for cross-repo triggers | -| `FRAMERS_BUMP_TOKEN` | Bump only | framerslab organization secret shared with this repository: the personal token the weekly bump pushes its branch and opens its pull request with, so the pull request's checks run | +| `FRAMERS_BUMP_TOKEN` | Bump and index | framerslab organization secret shared with this repository: the personal token the weekly bump and the index publish push their branches and open their pull requests with, so the pull requests' checks run | ## Manual Triggers diff --git a/README.md b/README.md index 19a30af..4a8c101 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ graph TD ``` **Workflows**: -- `.github/workflows/auto-index.yml` - Builds index on every commit (static NLP) +- `.github/workflows/auto-index.yml` - Builds the index (static NLP) when a push changes `weaves/`, `scripts/` or `schema/`, and on master opens or updates one PR from `ci/codex-index` with it, opened with `FRAMERS_BUMP_TOKEN` so its checks run; master is protected, so nothing pushes the index there - `.github/workflows/ai-enhance-pr.yml` - Optional AI analysis on PRs - `.github/workflows/auto-merge-weavers.yml` - Approves trusted contributors' PRs that pass validation and labels them `landing` - `.github/workflows/auto-categorize-inbox.yml` - Moves inbox files to their suggested place in a PR, labelled `landing` at 70 percent confidence or more @@ -112,7 +112,7 @@ graph TD - `.github/workflows/changelog.yml` - Daily changelog generation (git + GitHub API) - `.github/workflows/test.yml` - Runs validation suite (no API keys) - `.github/workflows/bump-framers-deps.yml` - Weekly pull request that moves the `@framers/*` dependencies to their newest npm versions, opened with `FRAMERS_BUMP_TOKEN` so its checks run -- `.github/workflows/landing-follows-the-bump-checks.yml` - Labels the weekly bump's PR `landing` once its Test Suite and rename-guard pass on its head, and keeps the label off a head they have not passed +- `.github/workflows/landing-follows-the-bump-checks.yml` - Labels the weekly bump's PR and the index's PR `landing` once its Test Suite and rename-guard pass on its head, and keeps the label off a head they have not passed ### Manual Overrides & Controls @@ -397,7 +397,7 @@ AI_PROVIDER=disabled ### Secret Usage - **GH_PAT**: Lets the Trusted Weavers workflow approve validated PRs and other workflows trigger across repositories; no workflow merges a PR -- **FRAMERS_BUMP_TOKEN**: A framerslab organization secret shared with this repository, not a repository secret. The weekly bump pushes its branch and opens its PR with it, because a PR a workflow opens with `GITHUB_TOKEN` gets no CI job; without it the bump stops at its first step +- **FRAMERS_BUMP_TOKEN**: A framerslab organization secret shared with this repository, not a repository secret. The weekly bump and the index publish push their branches and open their PRs with it, because a PR a workflow opens with `GITHUB_TOKEN` gets no CI job; without it each stops at its first step - **OPENAI_API_KEY**: Optional, enables AI quality analysis (cost varies by content length: ~$0.01-0.20/PR for 100-10K words) - **AI_PROVIDER**: Set to `disabled` to skip AI enhancement diff --git a/docs/BLOCK_TAGGING_TUTORIAL.md b/docs/BLOCK_TAGGING_TUTORIAL.md index 867d1b9..aaefadd 100644 --- a/docs/BLOCK_TAGGING_TUTORIAL.md +++ b/docs/BLOCK_TAGGING_TUTORIAL.md @@ -274,12 +274,14 @@ blocks: ### Submit Changes ```bash +git switch -c tutorial/block-tags git add weaves/tutorial/my-first-strand.md git commit -m "docs(tutorial): add block tags to my-first-strand" -git push +git push -u origin tutorial/block-tags +gh pr create --base master --fill ``` -Or submit a PR if you forked the repo. +master is protected, so a change reaches it through a pull request whose Test Suite and rename-guard checks pass. From a fork, push the branch to your fork and open the pull request from there. ## Part 7: Automation with GitHub Actions diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bf6c336..bd73bcc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -151,10 +151,13 @@ npm run index -- --validate # Review changes cat codex-report.json | jq '.summary' -# Commit manually +# Commit on a branch and open a pull request: master is protected, so a change reaches it +# through a pull request whose Test Suite and rename-guard checks pass +git switch -c chore/re-index git add codex-index.json codex-report.json git commit -m "chore: re-index all content" -git push +git push -u origin chore/re-index +gh pr create --base master --fill ``` **See full guide:** [RECATALOG_GUIDE.md](./RECATALOG_GUIDE.md) diff --git a/docs/RECATALOG_GUIDE.md b/docs/RECATALOG_GUIDE.md index 000e9b5..f07b9a7 100644 --- a/docs/RECATALOG_GUIDE.md +++ b/docs/RECATALOG_GUIDE.md @@ -112,11 +112,13 @@ npm run index -- --validate - `codex-index.json` - Full searchable index - `codex-report.json` - Analytics and validation report -**To Deploy:** +**To Deploy:** master is protected, so the files reach it through a pull request whose Test Suite and rename-guard checks pass: ```bash +git switch -c chore/manual-re-index git add codex-index.json codex-report.json git commit -m "chore: manual re-index" -git push +git push -u origin chore/manual-re-index +gh pr create --base master --fill ``` --- From 442ebed84eab392a09fa320bf1b2b0c145b22b27 Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 00:56:29 -0700 Subject: [PATCH 4/9] test: the index publish job checks out master's head, so its pull request starts from it and merges without a conflict after master took an earlier index pull request, and its ordering step reads the run's commit by its id and fails, deciding nothing, when it cannot --- tests/index-pull-request.test.js | 36 ++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tests/index-pull-request.test.js b/tests/index-pull-request.test.js index 33d1fcd..337eeee 100644 --- a/tests/index-pull-request.test.js +++ b/tests/index-pull-request.test.js @@ -106,6 +106,13 @@ describe('the index publish job opens a pull request into master, and pushes not expect(publish.if).toBe("github.ref == 'refs/heads/master'"); }); + // create-pull-request starts the pull request from the branch checked out. From the run's own commit, the pull request + // would carry master's later changes as a base older than master's, and would conflict with an earlier index pull + // request master took meanwhile. + it("checks out master's head, whichever commit the run built, so the pull request starts from it", () => { + expect(stepUsing(publish.steps, 'actions/checkout').with?.ref).toBe('master'); + }); + // GitHub skips the push and pull_request workflows of a commit whose message carries one of these, so a pull request // whose head commit carried one would get no Test Suite or rename-guard, and its required checks would never report. it('commits with a message that skips no workflow, so the pull request gets its required checks', () => { @@ -154,9 +161,11 @@ describe("a run leaves the index pull request to the run of a later change to th expect(at(download)).toBeLessThan(at(create())); }); - // origin holds master. The run's checkout holds the run's commit at depth 1, as actions/checkout leaves it; then master - // takes the change (or none), and the step runs in the checkout with the run's commit as GITHUB_SHA. - const runOrder = (change) => { + // origin holds master and serves a commit master reaches by its id, as GitHub does. The run built its commit; then + // master takes the change (or none), and the publish job's checkout holds master's head at depth 1, as actions/checkout + // leaves it with ref: master. The step runs in that checkout with the run's commit as GITHUB_SHA, or with runSha in its + // place. + const runOrder = (change, { runSha = null } = {}) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'index-order-')); const git = (cwd, ...args) => { const res = spawnSync( @@ -180,6 +189,7 @@ describe("a run leaves the index pull request to the run of a later change to th try { const origin = path.join(root, 'origin.git'); git(root, 'init', '--quiet', '--bare', origin); + git(origin, 'config', 'uploadpack.allowReachableSHA1InWant', 'true'); fs.mkdirSync(path.join(root, 'work')); git(path.join(root, 'work'), 'init', '--quiet'); git(path.join(root, 'work'), 'symbolic-ref', 'HEAD', 'refs/heads/master'); @@ -193,17 +203,16 @@ describe("a run leaves the index pull request to the run of a later change to th 'codex-index.json': '[]\n', }); const sha = git(path.join(root, 'work'), 'rev-parse', 'HEAD'); - git(root, 'clone', '--quiet', '--depth=1', '--branch', 'master', `file://${origin}`, 'checkout'); change(land); + git(root, 'clone', '--quiet', '--depth=1', '--branch', 'master', `file://${origin}`, 'checkout'); const output = path.join(root, 'output'); fs.writeFileSync(output, ''); const res = spawnSync('bash', ['-e', '-c', order().run], { cwd: path.join(root, 'checkout'), - env: { PATH: process.env.PATH, HOME: root, GITHUB_SHA: sha, GITHUB_OUTPUT: output }, + env: { PATH: process.env.PATH, HOME: root, GITHUB_SHA: runSha ?? sha, GITHUB_OUTPUT: output }, encoding: 'utf8', }); - expect(res.status, res.stdout + res.stderr).toBe(0); - return { output: fs.readFileSync(output, 'utf8'), stdout: res.stdout }; + return { status: res.status, output: fs.readFileSync(output, 'utf8'), stdout: res.stdout, stderr: res.stderr }; } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -215,7 +224,8 @@ describe("a run leaves the index pull request to the run of a later change to th ['a schema under schema/', 'schema/strand.schema.yaml'], ['this workflow', '.github/workflows/auto-index.yml'], ])("leaves the pull request when master changed %s after the run's commit", (_what, file) => { - const { output, stdout } = runOrder((land) => land('a later change', { [file]: 'changed\n' })); + const { status, output, stdout, stderr } = runOrder((land) => land('a later change', { [file]: 'changed\n' })); + expect(status, stdout + stderr).toBe(0); expect(output).toContain('later=true'); expect(output).not.toContain('later=false'); expect(stdout).toMatch(/^::notice::/m); @@ -233,8 +243,16 @@ describe("a run leaves the index pull request to the run of a later change to th }, ], ])('publishes when %s', (_what, change) => { - const { output } = runOrder(change); + const { status, output, stdout, stderr } = runOrder(change); + expect(status, stdout + stderr).toBe(0); expect(output).toContain('later=false'); expect(output).not.toContain('later=true'); }); + + // A comparison that cannot be made decides nothing: the step fails, and neither the download nor the pull request runs. + it('fails, deciding nothing, when it cannot read the run commit', () => { + const { status, output } = runOrder(() => {}, { runSha: 'f'.repeat(40) }); + expect(status).not.toBe(0); + expect(output).not.toMatch(/later=/); + }); }); From 862bdcc5facced89b887707aadca0569c1a3f704 Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 00:56:53 -0700 Subject: [PATCH 5/9] ci: the index publish job checks out master's head, so its pull request carries the index alone and merges without a conflict after master took an earlier index pull request; its ordering step reads the run's commit by its id, and a comparison that cannot be made fails the step instead of skipping the publish --- .github/workflows/auto-index.yml | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-index.yml b/.github/workflows/auto-index.yml index d3303e2..ddcec1c 100644 --- a/.github/workflows/auto-index.yml +++ b/.github/workflows/auto-index.yml @@ -96,22 +96,37 @@ jobs: exit 1 fi + # master's head, whichever commit this run built: create-pull-request starts the pull request from the branch checked + # out, so the pull request carries the index alone and merges without a conflict even after master took an earlier + # index pull request. - uses: actions/checkout@v5 + with: + ref: master # Each run waits for its own build, so a run of an earlier commit can get here after a later commit's run. The index # is built from the paths whose change starts this workflow (on.push.paths). When master's head differs from this # run's commit under them, a later push changed them and its run publishes, so this run leaves the pull request alone - # instead of putting an older index on it. + # instead of putting an older index on it. When they match, no later run publishes, so this one does. A comparison + # that cannot be made fails the step, and nothing is published. - name: Leave the index to the run of a later change id: order run: | - git fetch --quiet --depth=1 origin master - if git diff --quiet "$GITHUB_SHA" FETCH_HEAD -- weaves scripts schema .github/workflows/auto-index.yml; then - echo "later=false" >> "$GITHUB_OUTPUT" - else - echo "::notice::master changed weaves/, scripts/, schema/ or this workflow after ${GITHUB_SHA::7}; the run of that change publishes the index." - echo "later=true" >> "$GITHUB_OUTPUT" - fi + git fetch --quiet --depth=1 origin "$GITHUB_SHA" + status=0 + git diff --quiet "$GITHUB_SHA" HEAD -- weaves scripts schema .github/workflows/auto-index.yml || status=$? + case "$status" in + 0) + echo "later=false" >> "$GITHUB_OUTPUT" + ;; + 1) + echo "::notice::master changed weaves/, scripts/, schema/ or this workflow after ${GITHUB_SHA::7}; the run of that change publishes the index." + echo "later=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "::error::git diff could not compare ${GITHUB_SHA::7} with master's head (exit $status)." + exit "$status" + ;; + esac - uses: actions/download-artifact@v8 if: steps.order.outputs.later == 'false' From c37deae641e96e3f59995c763c70a5f3fed4431d Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 00:56:53 -0700 Subject: [PATCH 6/9] docs: the index publish job checks out master's head, and an ordering comparison that cannot be made fails the step --- .github/WORKFLOWS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index 10434f7..29bdc75 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -61,10 +61,10 @@ The Codex repository uses several GitHub Actions workflows for automated content **Purpose:** Builds the search index files, `codex-index.json`, `codex-search.json`, `codex-report.json` and `codex-blocks.json`, and on `master` publishes them through one pull request: - The build-index job builds the four files and archives them; on any other branch the run stops there -- On `master`, the publish-artifacts job opens or updates one pull request into `master` from `ci/codex-index` carrying them. It never pushes to `master`, which is protected (below): a commit made in a job has none of the required checks +- On `master`, the publish-artifacts job checks out `master`'s head and opens or updates one pull request into `master` from `ci/codex-index` carrying them, so the pull request carries the index alone and merges without a conflict even after `master` took an earlier index pull request. It never pushes to `master`, which is protected (below): a commit made in a job has none of the required checks - Pushes the branch and opens the pull request with `FRAMERS_BUMP_TOKEN`, as the weekly bump does, so the pull request gets Test Suite and rename-guard. Its commit message carries no skip marker: GitHub starts none of the `pull_request` workflows of a head commit whose message says `[skip ci]`, and the required checks would never report - Stops at its first step, with an error, when `FRAMERS_BUMP_TOKEN` is not available; both jobs' own `GITHUB_TOKEN` only reads, and the publish job installs and runs none of the repository's code -- Publishes one run at a time. Each run waits for its own build, so a run of an earlier commit can reach the publish job after a later commit's run; when `master`'s head differs from the run's commit under `weaves/`, `scripts/`, `schema/` or this workflow, a later push changed them and its run publishes, so the earlier run leaves the pull request alone +- Publishes one run at a time. Each run waits for its own build, so a run of an earlier commit can reach the publish job after a later commit's run; when `master`'s head differs from the run's commit under `weaves/`, `scripts/`, `schema/` or this workflow, a later push changed them and its run publishes, so the earlier run leaves the pull request alone. A comparison that cannot be made fails the step, and nothing is published - A later run replaces the branch with its own build, and closes the pull request when the index no longer differs from `master`'s - Labels nothing: `landing-follows-the-bump-checks.yml` adds `landing` once the pull request's Test Suite and rename-guard pass on its head, and the pull request lands after its review comments are read, answered and resolved - `tests/index-pull-request.test.js` reads the workflow, runs its first step, and runs its ordering step in scratch repositories, since GitHub runs the publish job only on `master` From aeceb700413af37562f3d1232a317ff705851ae9 Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 01:10:45 -0700 Subject: [PATCH 7/9] test: the index publish job keeps every waiting run, since GitHub's default keeps one and lets an older commit's run that begins waiting later cancel the newer commit's, and with every run kept only the run of the commit whose inputs master's head holds publishes, whatever order the runs take their turns in --- tests/index-pull-request.test.js | 68 ++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/tests/index-pull-request.test.js b/tests/index-pull-request.test.js index 337eeee..a06498e 100644 --- a/tests/index-pull-request.test.js +++ b/tests/index-pull-request.test.js @@ -140,9 +140,14 @@ describe('the index publish job opens a pull request into master, and pushes not expect(tracked).toEqual(archived); }); - it('publishes one run at a time and never cancels one that started, so two runs never push the branch at once', () => { + // GitHub keeps one waiting job in a concurrency group by default, and a job that begins waiting replaces it, whichever + // commit is newer: an older commit's build that finishes later would cancel the newer commit's waiting publish, then + // skip, and nothing would publish the newer index. queue: max keeps every waiting job, in the order each began to wait + // ("Control the concurrency of workflows and jobs", https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). + it('publishes one run at a time, never cancels one that started and keeps every waiting one', () => { expect(publish.concurrency?.group).toBeTruthy(); expect(publish.concurrency?.['cancel-in-progress']).toBe(false); + expect(publish.concurrency?.queue).toBe('max'); }); }); @@ -161,11 +166,11 @@ describe("a run leaves the index pull request to the run of a later change to th expect(at(download)).toBeLessThan(at(create())); }); - // origin holds master and serves a commit master reaches by its id, as GitHub does. The run built its commit; then + // origin holds master and serves a commit master reaches by its id, as GitHub does. The run built the first commit; then // master takes the change (or none), and the publish job's checkout holds master's head at depth 1, as actions/checkout - // leaves it with ref: master. The step runs in that checkout with the run's commit as GITHUB_SHA, or with runSha in its - // place. - const runOrder = (change, { runSha = null } = {}) => { + // leaves it with ref: master. decide runs the step in that checkout with a run's commit as GITHUB_SHA. change(land) may + // return the commits it landed, by name; land returns the commit it pushed. + const withMaster = (change, body) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'index-order-')); const git = (cwd, ...args) => { const res = spawnSync( @@ -185,6 +190,7 @@ describe("a run leaves the index pull request to the run of a later change to th git(path.join(root, 'work'), 'add', '--all'); git(path.join(root, 'work'), 'commit', '--quiet', '-m', message); git(path.join(root, 'work'), 'push', '--quiet', 'origin', 'master'); + return git(path.join(root, 'work'), 'rev-parse', 'HEAD'); }; try { const origin = path.join(root, 'origin.git'); @@ -194,7 +200,7 @@ describe("a run leaves the index pull request to the run of a later change to th git(path.join(root, 'work'), 'init', '--quiet'); git(path.join(root, 'work'), 'symbolic-ref', 'HEAD', 'refs/heads/master'); git(path.join(root, 'work'), 'remote', 'add', 'origin', `file://${origin}`); - land('the run commit', { + const first = land('the run commit', { 'weaves/frame/strand.md': 'a strand\n', 'scripts/auto-index.js': 'an indexer\n', 'schema/strand.schema.yaml': 'a schema\n', @@ -202,21 +208,27 @@ describe("a run leaves the index pull request to the run of a later change to th 'README.md': 'a readme\n', 'codex-index.json': '[]\n', }); - const sha = git(path.join(root, 'work'), 'rev-parse', 'HEAD'); - change(land); + const commits = change(land) ?? {}; git(root, 'clone', '--quiet', '--depth=1', '--branch', 'master', `file://${origin}`, 'checkout'); - const output = path.join(root, 'output'); - fs.writeFileSync(output, ''); - const res = spawnSync('bash', ['-e', '-c', order().run], { - cwd: path.join(root, 'checkout'), - env: { PATH: process.env.PATH, HOME: root, GITHUB_SHA: runSha ?? sha, GITHUB_OUTPUT: output }, - encoding: 'utf8', - }); - return { status: res.status, output: fs.readFileSync(output, 'utf8'), stdout: res.stdout, stderr: res.stderr }; + let runs = 0; + const decide = (sha) => { + runs += 1; + const output = path.join(root, `output-${runs}`); + fs.writeFileSync(output, ''); + const res = spawnSync('bash', ['-e', '-c', order().run], { + cwd: path.join(root, 'checkout'), + env: { PATH: process.env.PATH, HOME: root, GITHUB_SHA: sha, GITHUB_OUTPUT: output }, + encoding: 'utf8', + }); + return { status: res.status, output: fs.readFileSync(output, 'utf8'), stdout: res.stdout, stderr: res.stderr }; + }; + return body({ decide, first, commits }); } finally { fs.rmSync(root, { recursive: true, force: true }); } }; + // The step for the first commit's run, or for runSha in its place. + const runOrder = (change, { runSha = null } = {}) => withMaster(change, ({ decide, first }) => decide(runSha ?? first)); it.each([ ['a strand under weaves/', 'weaves/frame/strand.md'], @@ -255,4 +267,28 @@ describe("a run leaves the index pull request to the run of a later change to th expect(status).not.toBe(0); expect(output).not.toMatch(/later=/); }); + + // Every waiting run is kept (queue: max) and decides when its turn comes, in whatever order the runs began to wait: + // only the run of the commit whose inputs master's head holds publishes. + it.each([ + ['oldest first', ['first', 'older', 'newer']], + ['newest first', ['newer', 'older', 'first']], + ['newest between the two older ones', ['older', 'newer', 'first']], + ])("publishes the newest change's index alone when the waiting runs take their turns %s", (_what, turns) => { + withMaster( + (land) => ({ + older: land('an older change', { 'weaves/frame/strand.md': 'older\n' }), + newer: land('a newer change', { 'weaves/frame/strand.md': 'newer\n' }), + }), + ({ decide, first, commits }) => { + const shas = { first, ...commits }; + const publishing = turns.filter((name) => { + const { status, output, stdout, stderr } = decide(shas[name]); + expect(status, `${name}: ${stdout}${stderr}`).toBe(0); + return output.includes('later=false'); + }); + expect(publishing).toEqual(['newer']); + } + ); + }); }); From 9c1aac25d73775a7416bf5d707fcff224ac8070b Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 01:10:54 -0700 Subject: [PATCH 8/9] ci: the index publish job keeps every waiting run with queue: max, so an older commit's run that begins waiting later no longer cancels the newer commit's publish, and each run decides when its turn comes --- .github/workflows/auto-index.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-index.yml b/.github/workflows/auto-index.yml index ddcec1c..5bc9e33 100644 --- a/.github/workflows/auto-index.yml +++ b/.github/workflows/auto-index.yml @@ -78,10 +78,16 @@ jobs: needs: build-index runs-on: ubuntu-latest if: github.ref == 'refs/heads/master' - # One run at a time, so two runs never push the pull request branch at once; GitHub keeps the newest waiting run. + # One run at a time, so two runs never push the pull request branch at once. GitHub keeps one waiting job by default, + # and a job that begins waiting replaces it whichever commit is newer, so an older commit's build that finished later + # would cancel the newer commit's publish. queue: max keeps every waiting job, in the order each began to wait, and + # each decides when its turn comes (the ordering step below), so the run of the newest change publishes + # ("Control the concurrency of workflows and jobs": + # https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). concurrency: group: codex-index-pull-request cancel-in-progress: false + queue: max # FRAMERS_BUMP_TOKEN is a personal token that reaches past this repository, and a step can change what a later step of # its job runs, so this job installs and runs none of the repository's code, and its third-party action is pinned to a # commit, with the release that commit carries named beside it. From 254f551cbca72f56848aa12f129cfbadc0c46572 Mon Sep 17 00:00:00 2001 From: jddunn Date: Wed, 21 Oct 2026 01:10:54 -0700 Subject: [PATCH 9/9] docs: the index publish job keeps every waiting run --- .github/WORKFLOWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index 29bdc75..db2176e 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -64,7 +64,7 @@ Builds the search index files, `codex-index.json`, `codex-search.json`, `codex-r - On `master`, the publish-artifacts job checks out `master`'s head and opens or updates one pull request into `master` from `ci/codex-index` carrying them, so the pull request carries the index alone and merges without a conflict even after `master` took an earlier index pull request. It never pushes to `master`, which is protected (below): a commit made in a job has none of the required checks - Pushes the branch and opens the pull request with `FRAMERS_BUMP_TOKEN`, as the weekly bump does, so the pull request gets Test Suite and rename-guard. Its commit message carries no skip marker: GitHub starts none of the `pull_request` workflows of a head commit whose message says `[skip ci]`, and the required checks would never report - Stops at its first step, with an error, when `FRAMERS_BUMP_TOKEN` is not available; both jobs' own `GITHUB_TOKEN` only reads, and the publish job installs and runs none of the repository's code -- Publishes one run at a time. Each run waits for its own build, so a run of an earlier commit can reach the publish job after a later commit's run; when `master`'s head differs from the run's commit under `weaves/`, `scripts/`, `schema/` or this workflow, a later push changed them and its run publishes, so the earlier run leaves the pull request alone. A comparison that cannot be made fails the step, and nothing is published +- Publishes one run at a time, and keeps every waiting run (`queue: max`): GitHub's default keeps one, and a run that begins waiting replaces it whichever commit is newer. Each run waits for its own build, so a run of an earlier commit can reach the publish job after a later commit's run; when `master`'s head differs from the run's commit under `weaves/`, `scripts/`, `schema/` or this workflow, a later push changed them and its run publishes, so the earlier run leaves the pull request alone. A comparison that cannot be made fails the step, and nothing is published - A later run replaces the branch with its own build, and closes the pull request when the index no longer differs from `master`'s - Labels nothing: `landing-follows-the-bump-checks.yml` adds `landing` once the pull request's Test Suite and rename-guard pass on its head, and the pull request lands after its review comments are read, answered and resolved - `tests/index-pull-request.test.js` reads the workflow, runs its first step, and runs its ordering step in scratch repositories, since GitHub runs the publish job only on `master`