Skip to content

Commit a61375d

Browse files
committed
Update publishing of new version
1 parent cc71e70 commit a61375d

10 files changed

Lines changed: 290 additions & 79 deletions

File tree

‎.agents/skills/codex-kit-release/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ Inspect changes since the latest `v*` release tag together with relevant working
1111
2. For `major`, `minor`, or `patch`, replace all of `RELEASE_NOTES.md` with concise bullets for only the upcoming release, without a heading or history. For `none`, preserve it unchanged.
1212
3. Run `node .agents/skills/codex-kit-release/release.mjs bump <classification>` before final validation.
1313

14-
The script uses committed `package.json` as its baseline, is idempotent, and rejects malformed or conflicting versions, missing or empty notes, and bumps without changed notes. It never stages, commits, tags, pushes, publishes, or creates a release. Those actions always require explicit user authorization.
14+
The script uses committed `package.json` as its baseline, is idempotent, and rejects malformed or conflicting versions, missing or empty notes, and bumps without changed notes. It never stages, commits, tags, pushes, publishes, or creates a release. After an explicitly authorized release commit is pushed to `main`, the validated `publish.yml` workflow creates the immutable tag, publishes npm, and creates the GitHub Release. Agents never perform those actions interactively without explicit user authorization.

‎.agents/skills/codex-kit-release/release.mjs‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ function gitShow(revision, path) {
4040
}
4141
}
4242

43+
function git(...args) {
44+
try {
45+
return execFileSync("git", args, {
46+
encoding: "utf8",
47+
stdio: ["ignore", "pipe", "ignore"],
48+
}).trim();
49+
} catch {
50+
return undefined;
51+
}
52+
}
53+
4354
function manifest(source, label) {
4455
let value;
4556
try {
@@ -114,16 +125,57 @@ export function validateTag(tag, previousTag) {
114125
return current;
115126
}
116127

128+
export function releaseState(expectedTag) {
129+
const current = manifest(
130+
readFileSync("package.json", "utf8"),
131+
"package.json",
132+
).version;
133+
const tag = `v${current}`;
134+
if (expectedTag && expectedTag !== tag)
135+
fail(`tag ${expectedTag} does not match package.json version ${current}`);
136+
const head = git("rev-parse", "HEAD");
137+
if (!head) fail("HEAD is required");
138+
const taggedCommit = git("rev-parse", `${tag}^{commit}`);
139+
if (taggedCommit && taggedCommit !== head) {
140+
if (git("merge-base", "--is-ancestor", taggedCommit, head) !== undefined) {
141+
const taggedVersion = manifest(
142+
gitShow(tag, "package.json"),
143+
`${tag}:package.json`,
144+
).version;
145+
if (taggedVersion !== current)
146+
fail(`tag ${tag} contains package.json version ${taggedVersion}`);
147+
releaseNotes();
148+
return { state: "skip", tag };
149+
}
150+
fail(`tag ${tag} does not point to HEAD`);
151+
}
152+
const previousTag = git(
153+
"describe",
154+
"--tags",
155+
"--match",
156+
"v*",
157+
"--abbrev=0",
158+
"HEAD^",
159+
);
160+
if (!previousTag) fail("a previous v* release tag is required");
161+
validateTag(tag, previousTag);
162+
return { state: taggedCommit ? "retry" : "new", tag };
163+
}
164+
117165
if (process.argv[1] === fileURLToPath(import.meta.url)) {
118166
try {
119167
const [, , command, first, second] = process.argv;
120168
let version;
121169
if (command === "bump") version = bump(first);
122170
else if (command === "validate-tag" && first && second)
123171
version = validateTag(first, second);
124-
else
172+
else if (command === "release-state") {
173+
const release = releaseState(first);
174+
console.log(`state=${release.state}`);
175+
version = `tag=${release.tag}`;
176+
} else
125177
fail(
126-
"usage: release.mjs bump <kind> | validate-tag <tag> <previous-tag>",
178+
"usage: release.mjs bump <kind> | validate-tag <tag> <previous-tag> | release-state",
127179
);
128180
console.log(version);
129181
} catch (error) {

‎.agents/skills/codex-kit-release/release.node-test.mjs‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ function run(root, ...args) {
3333
});
3434
}
3535

36+
function commit(root, message = "release") {
37+
execFileSync("git", ["add", "."], { cwd: root });
38+
execFileSync("git", ["commit", "-qm", message], { cwd: root });
39+
}
40+
41+
function prepareRelease(root, version = "1.3.0") {
42+
writeFileSync(
43+
join(root, "package.json"),
44+
`${JSON.stringify({ name: "test", version }, null, "\t")}\n`,
45+
);
46+
writeFileSync(join(root, "RELEASE_NOTES.md"), "- New release\n");
47+
commit(root);
48+
}
49+
3650
test("bumps each classification and reruns idempotently", () => {
3751
for (const [kind, expected] of Object.entries({
3852
none: "1.2.3",
@@ -77,3 +91,71 @@ test("validates release tags, one-step versions, and changed notes", () => {
7791
assert.equal(run(root, "validate-tag", "v1.3.0", "v1.2.3").status, 0);
7892
assert.equal(run(root, "validate-tag", "v1.3.1", "v1.2.3").status, 1);
7993
});
94+
95+
test("classifies new, retry, already-released, and conflicting tags", () => {
96+
const root = repo();
97+
prepareRelease(root);
98+
assert.match(run(root, "release-state").stdout, /state=new\ntag=v1\.3\.0/);
99+
assert.equal(run(root, "release-state", "v1.3.1").status, 1);
100+
execFileSync("git", ["tag", "v1.3.0"], { cwd: root });
101+
assert.match(run(root, "release-state").stdout, /state=retry/);
102+
writeFileSync(join(root, "next.txt"), "next\n");
103+
commit(root, "next");
104+
assert.match(run(root, "release-state").stdout, /state=skip/);
105+
106+
const conflict = repo();
107+
execFileSync("git", ["tag", "v1.3.0"], { cwd: conflict });
108+
prepareRelease(conflict);
109+
assert.equal(run(conflict, "release-state").status, 1);
110+
});
111+
112+
test("release state rejects invalid versions and unchanged, empty, or missing notes", () => {
113+
for (const state of ["invalid", "unchanged", "empty", "missing"]) {
114+
const root = repo();
115+
if (state === "invalid") {
116+
writeFileSync(
117+
join(root, "package.json"),
118+
JSON.stringify({ version: "bad" }),
119+
);
120+
} else {
121+
writeFileSync(
122+
join(root, "package.json"),
123+
JSON.stringify({ version: "1.3.0" }),
124+
);
125+
if (state === "empty")
126+
writeFileSync(join(root, "RELEASE_NOTES.md"), "\n");
127+
if (state === "missing") unlinkSync(join(root, "RELEASE_NOTES.md"));
128+
}
129+
commit(root);
130+
assert.equal(run(root, "release-state").status, 1);
131+
}
132+
});
133+
134+
test("a pushed release tag targets the validated commit and retry never moves it", () => {
135+
const root = repo();
136+
prepareRelease(root);
137+
const remote = mkdtempSync(join(tmpdir(), "codex-kit-release-remote-"));
138+
execFileSync("git", ["init", "--bare", "-q", remote]);
139+
execFileSync("git", ["remote", "add", "origin", remote], { cwd: root });
140+
const tag = run(root, "release-state").stdout.match(/tag=(v\S+)/)?.[1];
141+
assert.equal(tag, "v1.3.0");
142+
execFileSync("git", ["tag", tag], { cwd: root });
143+
execFileSync("git", ["push", "-q", "origin", `refs/tags/${tag}`], {
144+
cwd: root,
145+
});
146+
const releasedCommit = execFileSync("git", ["rev-parse", "HEAD"], {
147+
cwd: root,
148+
encoding: "utf8",
149+
}).trim();
150+
assert.equal(run(root, "release-state").stdout.includes("state=retry"), true);
151+
assert.equal(
152+
execFileSync(
153+
"git",
154+
[`--git-dir=${remote}`, "rev-parse", `${tag}^{commit}`],
155+
{
156+
encoding: "utf8",
157+
},
158+
).trim(),
159+
releasedCommit,
160+
);
161+
});

‎.github/workflows/publish.yml‎

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,79 @@ name: Publish package
22

33
on:
44
push:
5+
branches:
6+
- main
57
tags:
68
- "v*"
79

10+
concurrency:
11+
group: publish-package
12+
cancel-in-progress: false
13+
814
jobs:
9-
publish:
15+
prepare:
1016
runs-on: ubuntu-latest
1117
permissions:
1218
contents: read
13-
id-token: write
19+
outputs:
20+
state: ${{ steps.release.outputs.state }}
21+
tag: ${{ steps.release.outputs.tag }}
22+
steps:
23+
- uses: actions/checkout@v6
24+
with:
25+
fetch-depth: 0
26+
- id: release
27+
name: Resolve release state
28+
env:
29+
PUSHED_TAG: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }}
30+
run: node .agents/skills/codex-kit-release/release.mjs release-state "$PUSHED_TAG" >> "$GITHUB_OUTPUT"
31+
- uses: pnpm/action-setup@v4
32+
if: steps.release.outputs.state != 'skip'
33+
with:
34+
version: 11.5.2
35+
- uses: actions/setup-node@v6
36+
if: steps.release.outputs.state != 'skip'
37+
with:
38+
node-version: 24
39+
registry-url: https://registry.npmjs.org
40+
package-manager-cache: false
41+
- if: steps.release.outputs.state != 'skip'
42+
run: npm install --global npm@11.5.1
43+
- if: steps.release.outputs.state != 'skip'
44+
run: pnpm install --frozen-lockfile
45+
- if: steps.release.outputs.state != 'skip'
46+
run: pnpm run typecheck
47+
- if: steps.release.outputs.state != 'skip'
48+
run: pnpm test
49+
- if: steps.release.outputs.state != 'skip'
50+
run: pnpm run pack:check
51+
52+
tag:
53+
needs: prepare
54+
if: needs.prepare.outputs.state != 'skip'
55+
runs-on: ubuntu-latest
56+
permissions:
57+
contents: write
1458
steps:
1559
- uses: actions/checkout@v6
1660
with:
1761
fetch-depth: 0
62+
- name: Create release tag
63+
if: needs.prepare.outputs.state == 'new'
64+
env:
65+
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
66+
run: |
67+
git tag "$RELEASE_TAG" "$GITHUB_SHA"
68+
git push origin "refs/tags/$RELEASE_TAG"
69+
70+
publish:
71+
needs: [prepare, tag]
72+
runs-on: ubuntu-latest
73+
permissions:
74+
contents: read
75+
id-token: write
76+
steps:
77+
- uses: actions/checkout@v6
1878
- uses: pnpm/action-setup@v4
1979
with:
2080
version: 11.5.2
@@ -25,16 +85,13 @@ jobs:
2585
package-manager-cache: false
2686
- run: npm install --global npm@11.5.1
2787
- run: pnpm install --frozen-lockfile
28-
- run: pnpm run typecheck
29-
- run: pnpm test
30-
- name: Validate release version and notes
31-
run: |
32-
previous_tag=$(git describe --tags --abbrev=0 "$GITHUB_REF_NAME^")
33-
node .agents/skills/codex-kit-release/release.mjs validate-tag "$GITHUB_REF_NAME" "$previous_tag"
34-
- run: npm publish --access public
88+
- name: Publish if absent from npm
89+
env:
90+
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
91+
run: npm view "@iamdevlinph/codex-kit@${RELEASE_TAG#v}" version >/dev/null 2>&1 || npm publish --access public
3592

3693
release:
37-
needs: publish
94+
needs: [prepare, publish]
3895
runs-on: ubuntu-latest
3996
permissions:
4097
contents: write
@@ -43,9 +100,10 @@ jobs:
43100
- name: Create or update GitHub Release
44101
env:
45102
GH_TOKEN: ${{ github.token }}
103+
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
46104
run: |
47-
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
48-
gh release edit "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file RELEASE_NOTES.md
105+
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
106+
gh release edit "$RELEASE_TAG" --title "$RELEASE_TAG" --notes-file RELEASE_NOTES.md
49107
else
50-
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file RELEASE_NOTES.md
108+
gh release create "$RELEASE_TAG" --title "$RELEASE_TAG" --notes-file RELEASE_NOTES.md
51109
fi

‎AGENTS.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@
9595
merge into or replace a project's `AGENTS.md`.
9696
- `project mark-applied` is bookkeeping only; it must not claim to validate the
9797
semantic merge or modify project instructions.
98-
- Do not add a `commit-pusher` role or automate Git publishing.
98+
- Do not add a `commit-pusher` role or automate Git publishing outside the
99+
validated `.github/workflows/publish.yml` release flow.
99100

100101
## Change requirements
101102

@@ -142,7 +143,8 @@
142143
- A template update requires a new package release before downstream
143144
`project sync` commands can receive it.
144145
- The release tag must match `package.json` as `v<version>`.
145-
- Never commit, tag, push, publish, or create a release unless the user explicitly
146-
requests that operation in the current task.
146+
- Never commit, tag, push, publish, or create a release interactively unless the
147+
user explicitly requests that operation in the current task. The validated
148+
release workflow may perform those actions after an authorized push to `main`.
147149
- Never store credentials, npm tokens, or personal access tokens in this
148150
repository.

‎MAINTAINERS.md‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,25 +113,26 @@ Inspect the dry-run contents. They should contain only:
113113
`codex-kit-release` skill, `src/`, tests, lockfiles, generated test output,
114114
credentials, and local backups must not appear.
115115

116-
After reviewing the prepared version and notes, explicitly authorize and create
117-
the release commit and matching tag, then push:
116+
After reviewing the prepared version and notes, explicitly authorize and push
117+
the release commit to `main`:
118118

119119
```sh
120120
git add package.json RELEASE_NOTES.md
121121
git commit -m "v<version>"
122-
git tag v<version>
123-
git push origin main --follow-tags
122+
git push origin main
124123
```
125124

126-
The workflow checks that the tag matches `package.json`, the version is one
127-
SemVer increment from the previous tag, and latest-only `RELEASE_NOTES.md` is
128-
non-empty and changed. It publishes npm first, then creates or updates the
129-
public GitHub Release from the complete notes file.
125+
The workflow checks that the version is one SemVer increment from the previous
126+
tag and latest-only `RELEASE_NOTES.md` is non-empty and changed. After all checks
127+
pass, it creates the immutable matching tag, publishes npm, then creates or
128+
updates the public GitHub Release from the complete notes file. A push whose
129+
version tag belongs to an ancestor skips cleanly.
130130

131131
If a workflow fails because of external configuration and no source change is
132-
needed, rerun the failed job. A rerun uses the original tag and commit. If code
133-
or workflow changes are required, publish a new version unless the failed tag
134-
was never released and is intentionally deleted and recreated.
132+
needed, rerun the failed job. If the tag already targets the tested commit, the
133+
workflow reuses it; if npm already contains the version, it continues with the
134+
GitHub Release. A manual push of the matching `v*` tag remains the recovery path.
135+
The workflow never moves an existing tag.
135136

136137
## Promote a reusable guideline
137138

‎RELEASE_NOTES.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
- Expand reconciliation and initialization audits to classify existing and incoming guidance, preserve local adaptations, and validate narrow workflow routing.
33
- Shorten project prompts so the packaged reconciliation skill owns semantic restructuring, with updated upgrade and global-refresh guidance.
44
- Add read-only `project audit` prompting and an explicit-only audit skill managed by global install, list, and uninstall.
5+
- Update Vitest to `4.1.11` to resolve its development-server security advisory.
6+
- Automatically validate, tag, publish, and create the GitHub Release for release-ready pushes to `main`.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"husky": "9.1.7",
4747
"lint-staged": "17.0.8",
4848
"typescript": "7.0.1-rc",
49-
"vitest": "4.1.10"
49+
"vitest": "4.1.11"
5050
},
5151
"packageManager": "pnpm@11.5.2",
5252
"license": "ISC",

0 commit comments

Comments
 (0)