fix(ci): authenticate releases with a scoped credential helper
Release / release (push) Successful in 3m55s
CI / build (push) Successful in 2m2s

This commit is contained in:
dmg committed 2026-09-16 22:17:27 -04:00
1 parent 421b302664
commit 638965e474
7 files changed
+91 -61

No files matched your search

+3 -2
View File
@@ -60,10 +60,11 @@ jobs:
unset BB_TOKEN BITBUCKET_TOKEN BB_TOKEN_BASIC_AUTH BITBUCKET_TOKEN_BASIC_AUTH
unset DEBUG NODE_DEBUG GIT_TRACE GIT_TRACE_CURL GIT_CURL_VERBOSE GIT_TRACE_PACKET
unset GIT_TRACE_SETUP GIT_TRACE2 GIT_TRACE2_EVENT GIT_TRACE2_PERF
export GIT_ASKPASS="$GITHUB_WORKSPACE/scripts/git-askpass.sh"
# semantic-release forces GIT_ASKPASS=echo. Use a scoped, memory-only helper instead.
export GIT_TERMINAL_PROMPT=0
git config --local credential.helper ''
git config --local credential.useHttpPath false
git config --local --add credential.helper "$GITHUB_WORKSPACE/scripts/git-credential.sh"
git config --local credential.useHttpPath true
git config --local http.followRedirects false
npx -y \
-p semantic-release@24.2.9 \
+4 -4
View File
@@ -1,8 +1,8 @@
# Fantasy Stick
**Bootstrap only. Gameplay is not implemented and this JAR is not ready for deployment.**
**Development foundation only. No playable gameplay is registered, and this JAR is not ready for deployment.**
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These are planned features, not claims about this build.
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These remain planned native features. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. It is not yet connected to crafting, native health, the book or persistence.
## Build and verify
@@ -21,11 +21,11 @@ Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, te
## CI and publication
Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The first source push is currently gated on approved `RELEASE_TOKEN` provisioning. No remote workflow has run yet.
Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
The release credential must be a repository-write credential authorized for `dmg/purpur-fantasy-stick`. Provision it through the approved protected-file/stdin workflow; never paste it into chat, commit it, or supply it as a command argument.
- Git receives the credential through the restricted `git-askpass.sh` pipe, for HTTPS prompts on `git.garvis.dev` as `dmg` only. Recognized semantic-release token environment names are unset so they cannot become credential-bearing Git URLs. Git HTTP redirects and credential-helper storage are disabled.
- Git receives the credential through `git-credential.sh` using Git's pipe-based credential protocol, restricted to HTTPS, `git.garvis.dev`, this exact repository path and username `dmg`. The helper ignores store/erase requests and never writes the token. Recognized semantic-release token environment names are unset so they cannot become credential-bearing Git URLs. HTTP redirects are disabled. This works even when semantic-release overrides `GIT_ASKPASS`.
- API upload uses an in-process TypeScript client, a fixed repository URL, timeouts, redirect refusal and sanitized failures. Credentials are read from the release step's environment, never arguments or generated request files.
- Publication creates a draft, uploads/checks the expected asset name and size, then publishes. Failed calls are not automatically retried, existing releases/assets are not overwritten, and raw errors/response bodies are not logged.
- A failed run may leave a tag or draft. Inspect server-side state before proposing an approved recovery; rerunning does not imply a new release and must not overwrite an immutable asset. Verify downloaded bytes/checksum and embedded JAR version separately after publication.
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"engines": { "node": ">=20" },
"scripts": {
"test": "tsc && node --test build/release-tools/*.test.js"
"test": "rm -rf build/release-tools && tsc && node --test build/release-tools/*.test.js"
},
"devDependencies": {
"@types/node": "20.19.9",
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# Git captures stdout through its askpass pipe. Never invoke with a credential argument.
set +x
case "${1-}" in
"Username for 'https://git.garvis.dev':"|"Username for 'https://git.garvis.dev': ")
printf '%s\n' dmg
;;
"Password for 'https://dmg@git.garvis.dev':"|"Password for 'https://dmg@git.garvis.dev': ")
test -n "${RELEASE_CREDENTIAL-}" || exit 1
printf '%s\n' "$RELEASE_CREDENTIAL"
;;
*) exit 1 ;;
esac
-41
View File
@@ -1,41 +0,0 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
const credential = "synthetic-askpass-fixture";
function ask(prompt: string, value = credential) {
return spawnSync("/bin/sh", ["scripts/git-askpass.sh", prompt], {
encoding: "utf8", env: { RELEASE_CREDENTIAL: value, LC_ALL: "C" },
});
}
test("gives Git its username and password over captured stdout, never credential argv", () => {
for (const suffix of ["", " "]) {
const username = ask(`Username for 'https://git.garvis.dev':${suffix}`);
const password = ask(`Password for 'https://dmg@git.garvis.dev':${suffix}`);
assert.equal(username.status, 0);
assert.equal(username.stdout, "dmg\n");
assert.equal(password.status, 0);
assert.equal(password.stdout, `${credential}\n`);
assert.equal(password.stderr, "");
}
});
for (const prompt of [
"Password for 'https://dmg@git.garvis.dev.evil.invalid': ",
"Password for 'http://dmg@git.garvis.dev': ",
"Password for 'https://other@git.garvis.dev': ", "unexpected prompt",
]) {
test(`refuses non-approved authentication prompts: ${prompt}`, () => {
const result = ask(prompt);
assert.notEqual(result.status, 0);
assert.equal(result.stdout, "");
assert.equal(result.stderr, "");
});
}
test("missing release credential fails without falling back to another secret", () => {
const result = ask("Password for 'https://dmg@git.garvis.dev': ", "");
assert.notEqual(result.status, 0);
assert.equal(result.stdout, "");
});
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# Git consumes this protocol over a pipe; never run interactively with a real credential.
# semantic-release overrides GIT_ASKPASS, but still honors Git credential helpers.
set +x
[ "${1-}" = get ] || exit 0
protocol= host= path= username=
while IFS= read -r line && [ -n "$line" ]; do
case "$line" in
protocol=*) protocol=${line#protocol=} ;;
host=*) host=${line#host=} ;;
path=*) path=${line#path=} ;;
username=*) username=${line#username=} ;;
esac
done
[ "$protocol" = https ] || exit 1
[ "$host" = git.garvis.dev ] || exit 1
[ "$path" = dmg/purpur-fantasy-stick.git ] || exit 1
[ -z "$username" ] || [ "$username" = dmg ] || exit 1
[ -n "${RELEASE_CREDENTIAL-}" ] || exit 1
LC_ALL=C
export LC_ALL
case "$RELEASE_CREDENTIAL" in *[![:graph:]]*) exit 1 ;; esac
printf '%s\n' 'username=dmg'
printf 'password=%s\n\n' "$RELEASE_CREDENTIAL"
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { test } from "node:test";
const fixture = "synthetic-git-protocol-fixture";
const helper = resolve("scripts/git-credential.sh");
const request = "protocol=https\nhost=git.garvis.dev\npath=dmg/purpur-fantasy-stick.git\n\n";
const environment = {
PATH: process.env.PATH, RELEASE_CREDENTIAL: fixture, LC_ALL: "C",
GIT_ASKPASS: "echo", GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null",
};
test("real Git obtains the credential even when semantic-release forces GIT_ASKPASS=echo", () => {
const child = spawnSync("git", [
"-c", "credential.helper=", "-c", `credential.helper=${helper}`,
"-c", "credential.useHttpPath=true", "credential", "fill",
], { encoding: "utf8", input: request, env: environment });
assert.equal(child.status, 0);
assert.ok(child.stdout.split("\n").includes("username=dmg"));
assert.ok(child.stdout.split("\n").includes(`password=${fixture}`));
assert.equal(child.stderr, "");
});
for (const input of [
request.replace("https", "http"), request.replace("git.garvis.dev", "evil.invalid"),
request.replace("purpur-fantasy-stick.git", "another-project.git"),
request.replace("\n\n", "\nusername=other\n\n"),
]) {
test(`refuses mismatched credential scope: ${JSON.stringify(input)}`, () => {
const child = spawnSync("/bin/sh", [helper, "get"], { encoding: "utf8", input, env: environment });
assert.notEqual(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}
for (const action of ["store", "erase"]) {
test(`ignores ${action} without emitting or persisting credentials`, () => {
const child = spawnSync("/bin/sh", [helper, action], {
encoding: "utf8", input: request, env: environment,
});
assert.equal(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}
for (const value of ["", "bad\npassword=injected", "trailing-space "]) {
test(`rejects absent or malformed credential input of length ${value.length}`, () => {
const child = spawnSync("/bin/sh", [helper, "get"], {
encoding: "utf8", input: request, env: { ...environment, RELEASE_CREDENTIAL: value },
});
assert.notEqual(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}