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
18 changes: 5 additions & 13 deletions .github/actions/aggregate-report/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ inputs:
working-directory:
description: 'Path to the ./packages/name_of_your_package_folder'
required: true
outputs:
report:
description: 'Markdown report section generated by the action'
value: ${{ steps.section-report.outputs.report }}
runs:
using: 'composite'
steps:
Expand All @@ -35,10 +31,6 @@ runs:
name: mutation-report
path: mutation-report

- name: Find current PR's number
uses: jwalton/gh-find-current-pr@v1
id: find_pr

- name: Process coverage reports
uses: actions/github-script@v9
id: process_test_report
Expand All @@ -55,7 +47,7 @@ runs:
id: process_mutation_report
env:
WORKING_DIR: ${{ inputs.working-directory }}
PR_NUMBER: ${{ steps.find_pr.outputs.number }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PACKAGE_NAME: ${{ inputs.package-name }}
with:
script: |
Expand All @@ -65,16 +57,16 @@ runs:

- name: Acquire comment-update lock
id: acquire_lock
uses: softprops/turnstyle@v3
# Pinned to a full SHA (v3.3.2) rather than the moving v3 tag.
uses: softprops/turnstyle@afaccda0f3c0136fb7cb4a734b9b96be03599948 # v3.3.2
with:
queue-name: aggregated-comment-${{ steps.find_pr.outputs.number || github.ref_name || github.ref }}}}
queue-name: aggregated-comment-${{ github.event.pull_request.number }}

- name: Create or update PR comment with report
uses: actions/github-script@v9
env:
REPORT: ${{ steps.section-report.outputs.report }}
PACKAGE: ${{ inputs.package-name }}
PR_NUMBER: ${{ steps.find_pr.outputs.number }}
PR_NUMBER: ${{ github.event.pull_request.number }}
MUTATION_MESSAGE: ${{ steps.process_mutation_report.outputs.message }}
UNIT_TEST_MESSAGE: ${{ steps.process_test_report.outputs.message }}
with:
Expand Down
8 changes: 5 additions & 3 deletions .github/actions/mutation-tests-all-files/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,23 @@ runs:
package-name: ${{ inputs.package-name }}

- name: Get changed files
uses: tj-actions/changed-files@v47
# Pinned to a full SHA (v47.0.6) — see mutation-tests-changed-files for why.
# Only the src_any_changed boolean is consumed below, so no separator is set.
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
id: changed-files
with:
files_yaml: |
src:
- 'src/**/*.ts'
- '!src/**/*.spec.ts'
- '!src/**/__mocks__/**'
separator: "','"
path: ${{ inputs.working-directory }}

- name: Run mutation tests
if: steps.changed-files.outputs.src_any_changed == 'true'
env:
STRYKER_DASHBOARD_API_KEY: ${{ inputs.stryker_dashboard_api_key }}
PACKAGE_NAME: ${{ inputs.package-name }}
shell: bash
id: run-mutation-tests
run: yarn workspace ${{ inputs.package-name }} test:mutations --dashboard.version main --dashboard.module ${{ inputs.package-name }}
run: yarn workspace "$PACKAGE_NAME" test:mutations --dashboard.version main --dashboard.module "$PACKAGE_NAME"
17 changes: 14 additions & 3 deletions .github/actions/mutation-tests-changed-files/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ runs:
package-name: ${{ inputs.package-name }}

- name: Get changed files
uses: tj-actions/changed-files@v47
# Pinned to a full SHA (v47.0.6). tj-actions/changed-files was compromised
# in March 2025 via tag re-pointing; a moving tag re-exposes that risk.
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
id: changed-files
with:
files_yaml: |
Expand All @@ -42,16 +44,25 @@ runs:
if: steps.changed-files.outputs.src_any_changed == 'true'
env:
STRYKER_DASHBOARD_API_KEY: ${{ inputs.stryker_dashboard_api_key }}
# Via env, never inlined: these are filenames from the PR, and `${{ }}`
# splices them into the shell — a file named `$(...)` would execute.
CHANGED_FILES: ${{ steps.changed-files.outputs.src_all_changed_files }}
PACKAGE_NAME: ${{ inputs.package-name }}
shell: bash
id: run-mutation-tests
run: yarn workspace ${{ inputs.package-name }} test:mutations --mutate "${{ steps.changed-files.outputs.src_all_changed_files }}" --dashboard.module ${{ inputs.package-name }}
run: yarn workspace "$PACKAGE_NAME" test:mutations --mutate "$CHANGED_FILES" --dashboard.module "$PACKAGE_NAME"
continue-on-error: true

- name: Save mutated files list
if: steps.changed-files.outputs.src_any_changed == 'true'
shell: bash
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.src_all_changed_files }}
run: |
echo "${{ steps.changed-files.outputs.src_all_changed_files }}" | sed 's/,/","/g' | sed 's/^/["/;s/$/"]/' > changed-files.json
# Filenames are PR-controlled; splicing them into JSON by hand (sed)
# breaks on a `"` or `\` in a path. jq splits the comma-separated list
# and serialises each element as a properly escaped JSON string.
jq -cn --arg files "$CHANGED_FILES" '$files | split(",")' > changed-files.json

- name: Save empty list if no files changed
if: steps.changed-files.outputs.src_any_changed == 'false'
Expand Down
1 change: 0 additions & 1 deletion .github/actions/unit-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ runs:
package-name: ${{ inputs.package-name }}

- name: Run unit tests with coverage
if: ${{ github.ref != 'refs/heads/main' }} # Skip coverage on main branch to save time and resources
shell: bash
run: |
yarn workspace ${{ inputs.package-name }} test:coverage
Expand Down
105 changes: 105 additions & 0 deletions .github/scripts/openspec-archive-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env node
// OpenSpec archive gate.
//
// Fails when an active change is fully complete (every task in its tasks.md is
// checked) but still lives in openspec/changes/ instead of being archived into
// openspec/changes/archive/. Intermediate PRs of a multi-PR change leave some
// tasks unchecked and therefore pass; only the PR that checks the last task is
// required to carry the `openspec archive` commit.
//
// Pass --defer (or OPENSPEC_DEFER_ARCHIVE=true) to downgrade the failure to a
// warning — used when a PR carries the `openspec:defer-archive` label.
//
// Pass --list-complete to instead print the names of complete-but-un-archived
// changes (one per line) and exit 0 — used by the archive workflow to resolve
// which change `/archive` should target.
//
// Read-only. Run locally with: node .github/scripts/openspec-archive-gate.mjs

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const deferred =
process.argv.includes('--defer') ||
/^(1|true|yes)$/i.test(process.env.OPENSPEC_DEFER_ARCHIVE ?? '');

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const changesDir = join(repoRoot, 'openspec', 'changes');

/** Directory names of active (non-archived) changes. */
function activeChanges() {
let entries;
try {
entries = readdirSync(changesDir, { withFileTypes: true });
} catch {
return []; // no openspec/changes directory yet
}
return entries
.filter((e) => e.isDirectory() && e.name !== 'archive')
.map((e) => e.name);
}

/**
* Task completeness for a change, read straight from its tasks.md checkboxes.
* Returns null when there are no task lines (nothing to complete / no gate).
*/
function completeness(change) {
const tasksPath = join(changesDir, change, 'tasks.md');
let content;
try {
if (!statSync(tasksPath).isFile()) return null;
content = readFileSync(tasksPath, 'utf8');
} catch {
return null;
}
let total = 0;
let unchecked = 0;
for (const line of content.split('\n')) {
const m = /^\s*[-*]\s+\[( |x|X)\]/.exec(line);
if (!m) continue;
total += 1;
if (m[1] === ' ') unchecked += 1;
}
if (total === 0) return null;
return { total, unchecked, complete: unchecked === 0 };
}

const violations = [];
for (const change of activeChanges()) {
const status = completeness(change);
if (status?.complete) violations.push(change);
}

if (process.argv.includes('--list-complete')) {
console.log(violations.join('\n'));
process.exit(0);
}

if (violations.length === 0) {
console.log('OpenSpec archive gate: OK — no completed change is left un-archived.');
process.exit(0);
}

// GitHub workflow commands (`::error::` / `::warning::`) treat the message as a
// single line: a raw newline ends the command and the rest leaks to the log as
// plain text. Percent-encode so multi-line details land inside the annotation.
// https://docs.github.com/actions/reference/workflow-commands-for-github-actions
const encodeCmd = (s) =>
s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');

const list = violations.map((c) => ` - ${c}`).join('\n');
const message =
`OpenSpec archive gate: the following change(s) are complete but not archived:\n${list}\n\n` +
`Run \`openspec archive -y <change>\` (or comment \`/archive\` on the PR) so the ` +
`delta specs fold into openspec/specs/ before this merges.`;

if (deferred) {
console.warn(
`::warning::${encodeCmd(`${message}\n(Deferred via openspec:defer-archive — not blocking.)`)}`,
);
process.exit(0);
}

console.error(`::error::${encodeCmd(message)}`);
process.exit(1);
20 changes: 20 additions & 0 deletions .github/scripts/should-run-ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ if [ -z "$RESOLVED_BASE_REF" ]; then
exit 0
fi

# Shared/root files that can affect every package's install, build, or CI. A change
# to any of these forces CI regardless of which package this is - without it, a
# lockfile bump or a workflow edit makes every package report "no CI needed" and the
# PR shows all-green having tested nothing. Checked before workspace resolution since
# it is independent of it (and `.github/` changes should run CI even if yarn can't).
ROOT_CHANGES=$(git diff --name-only "$RESOLVED_BASE_REF...HEAD" -- \
yarn.lock \
package.json \
.yarnrc.yml \
yarn.config.cjs \
.nvmrc \
tsconfig.json \
.github/ 2>/dev/null)

if [ -n "$ROOT_CHANGES" ]; then
debug "✓ Shared/root files changed, running CI:"
debug "$ROOT_CHANGES"
exit 0
fi

# Build an index of workspace packages: one "<package-name> <directory>" record per line.
# Locations come from yarn itself, so any workspace layout (nested roots like packages/tools/*
# and packages/plugins/*, or anything added later) is picked up without teaching this script
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/build-and-push-docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

# Explicit, least-privilege: pushing to ghcr.io needs packages: write, which the
# default token only carries when the repo default is read-write.
permissions:
contents: read
packages: write

# Two quick merges to main must not race for the same tags.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ on:
- main
workflow_dispatch:

permissions:
contents: read

# Never publish two runs at once — concurrent `npm publish` of the same version
# races. Queue instead of cancelling: a cancelled run can leave half the
# workspaces published.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/openspec-archive-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: OpenSpec archive gate

# Fails a PR / merge-queue build when a completed OpenSpec change is left
# un-archived. The merge_group run is the authoritative one: the merge-queue ref is
# rebuilt fresh, so it re-evaluates with any archive commit present, whereas the
# pull_request run can go stale (the /archive workflow pushes with GITHUB_TOKEN,
# which does not re-trigger PR checks — re-run it manually to clear the red X).
# Add "OpenSpec archive gate / gate" to the merge queue's required status checks.

on:
pull_request:
merge_group:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
gate:
name: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Check archive readiness
env:
# Evaluates to false on merge_group (no pull_request in the payload), so
# the label softens the PR check only — the queue gate stays hard.
OPENSPEC_DEFER_ARCHIVE: ${{ contains(github.event.pull_request.labels.*.name, 'openspec:defer-archive') }}
run: node .github/scripts/openspec-archive-gate.mjs
Loading
Loading