Skip to content
Draft
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
423 changes: 423 additions & 0 deletions .github/release-notes/MEMOS_LOCAL_PLUGIN_RELEASE_FLOW_ZH.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions .github/release-notes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Repository release notes

For the complete MemOS and embedded local-plugin release flow, safeguards, and recovery guide, see [MEMOS_LOCAL_PLUGIN_RELEASE_FLOW_ZH.md](./MEMOS_LOCAL_PLUGIN_RELEASE_FLOW_ZH.md).

Release branches may include an optional MemOS release overview at:

```text
.github/release-notes/vX.Y.Z.md
```

For example, `dev-v2.0.30` may add `.github/release-notes/v2.0.30.md`.

The `MemOS Release — Publish` workflow reads the file from the exact release target commit and places it before GitHub's generated `What's Changed` section. Keep it short and product-facing. Start with a section such as `## Highlights`; do not copy commit lists that GitHub already generates.

This file is optional. It does not decide whether the embedded MemOS local plugin is released, does not replace path-filtered git evidence, and must not contain Doc Agent payloads, binding markers, tokens, internal service URLs, or other credentials. Local-plugin Plugin tab copy still comes from `apps/memos-local-plugin/**` evidence and must pass source-ref, bilingual, coverage, and repair validation.
653 changes: 596 additions & 57 deletions .github/scripts/prepare-memos-release.mjs

Large diffs are not rendered by default.

603 changes: 580 additions & 23 deletions .github/scripts/prepare-memos-release.test.mjs

Large diffs are not rendered by default.

135 changes: 133 additions & 2 deletions .github/scripts/publish-local-plugin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ npm_visibility_interval_seconds="${NPM_VISIBILITY_INTERVAL_SECONDS:-10}"
npm_visibility_request_timeout_seconds="${NPM_VISIBILITY_REQUEST_TIMEOUT_SECONDS:-8}"
npm_registry_url="https://registry.npmjs.org"
release_metadata_state="${RELEASE_METADATA_STATE:-fresh}"
allow_staged_tag_before_npm="${ALLOW_STAGED_TAG_BEFORE_NPM:-false}"

validate_positive_integer() {
local name="$1"
Expand All @@ -40,6 +41,14 @@ case "${release_metadata_state}" in
;;
esac

case "${allow_staged_tag_before_npm}" in
true|false) ;;
*)
echo "::error::ALLOW_STAGED_TAG_BEFORE_NPM must be true or false; received ${allow_staged_tag_before_npm}."
exit 2
;;
esac

script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
npm_view_log="${RUNNER_TEMP}/memos-local-plugin-npm-view.log"

Expand Down Expand Up @@ -109,6 +118,123 @@ npm_dist_tag_matches() {
' "${output_file}" "${NPM_DIST_TAG}" "${RELEASE_VERSION}"
}

read_current_npm_dist_tag() {
local output_file="${RUNNER_TEMP}/memos-local-plugin-npm-dist-tags-preflight.json"
local attempt
local status

for attempt in 1 2 3; do
set +e
npm view "${PACKAGE_NAME}" dist-tags \
--json \
--prefer-online \
--fetch-retries=0 \
--fetch-timeout=8000 \
--registry="${npm_registry_url}" \
>"${output_file}" 2>&1
status=$?
set -e
if [ "${status}" = 0 ]; then
node -e '
const fs = require("node:fs");
const tags = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const value = tags[process.argv[2]];
if (value !== undefined && typeof value !== "string") {
throw new Error(`npm dist-tag ${process.argv[2]} is not a string`);
}
process.stdout.write(value || "");
' "${output_file}" "${NPM_DIST_TAG}"
return 0
fi
if grep -Eiq "E404|404 Not Found|is not in this registry" "${output_file}"; then
return 0
fi
sed -n '1,120p' "${output_file}" >&2
if [ "${attempt}" = 3 ]; then
echo "::error::Failed to inspect npm dist-tag ${NPM_DIST_TAG} after three attempts; refusing to publish without a channel monotonicity check." >&2
exit "${status}"
fi
sleep "$((attempt * 5))"
done
}

ensure_npm_dist_tag_will_not_regress() {
local current_version
local comparison
local comparison_status

current_version="$(read_current_npm_dist_tag)"
if [ -z "${current_version}" ]; then
echo "npm dist-tag ${NPM_DIST_TAG} is not set; the new release may initialize it."
return 0
fi

set +e
comparison="$(node -e '
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
function parse(value) {
const match = SEMVER.exec(value);
if (!match) throw new Error(`invalid SemVer: ${value}`);
return {
core: match.slice(1, 4).map(Number),
pre: match[4] === undefined ? null : match[4].split("."),
};
}
function compareIdentifier(left, right) {
const leftNumeric = /^\d+$/.test(left);
const rightNumeric = /^\d+$/.test(right);
if (leftNumeric && rightNumeric) return Number(left) - Number(right);
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
return left === right ? 0 : left < right ? -1 : 1;
}
function compare(leftValue, rightValue) {
const left = parse(leftValue);
const right = parse(rightValue);
for (let index = 0; index < 3; index += 1) {
if (left.core[index] !== right.core[index]) return left.core[index] - right.core[index];
}
if (left.pre === null || right.pre === null) {
if (left.pre === right.pre) return 0;
return left.pre === null ? 1 : -1;
}
const length = Math.max(left.pre.length, right.pre.length);
for (let index = 0; index < length; index += 1) {
if (left.pre[index] === undefined) return -1;
if (right.pre[index] === undefined) return 1;
const result = compareIdentifier(left.pre[index], right.pre[index]);
if (result !== 0) return result;
}
return 0;
}
process.stdout.write(String(Math.sign(compare(process.argv[1], process.argv[2]))));
' "${current_version}" "${RELEASE_VERSION}" 2>&1)"
comparison_status=$?
set -e
if [ "${comparison_status}" != 0 ]; then
echo "${comparison}" >&2
echo "::error::Could not compare npm dist-tag ${NPM_DIST_TAG} value ${current_version} with ${RELEASE_VERSION}; refusing to publish."
exit 1
fi

case "${comparison}" in
1)
echo "::error::Refusing to move npm dist-tag ${NPM_DIST_TAG} backwards from ${current_version} to ${RELEASE_VERSION}. Another release has already advanced this channel."
exit 1
;;
0)
echo "::error::npm dist-tag ${NPM_DIST_TAG} already points to ${RELEASE_VERSION}, but npm reports that version as absent. Refusing to publish against inconsistent registry metadata."
exit 1
;;
-1)
echo "npm dist-tag ${NPM_DIST_TAG} currently points to ${current_version}; advancing it to ${RELEASE_VERSION} is allowed."
;;
*)
echo "::error::Unexpected SemVer comparison result: ${comparison}."
exit 1
;;
esac
}

remote_tag_exists() {
local release_tag="$1"
local attempt
Expand Down Expand Up @@ -220,10 +346,15 @@ if npm_version_exists; then
fi
else
if [ "${release_metadata_state}" != "fresh" ]; then
echo "::error::Tag state is ${release_metadata_state}, but ${PACKAGE_NAME}@${RELEASE_VERSION} is absent from npm. Refusing to publish after tag metadata already exists."
exit 1
if [ "${allow_staged_tag_before_npm}" != "true" ]; then
echo "::error::Tag state is ${release_metadata_state}, but ${PACKAGE_NAME}@${RELEASE_VERSION} is absent from npm. Refusing to publish after tag metadata already exists."
exit 1
fi
echo "::notice::Tag state is ${release_metadata_state}; publishing npm after a staged paired local-plugin Draft Release."
fi

ensure_npm_dist_tag_will_not_regress

if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "::error::NPM_TOKEN is missing; refusing a real npm publish."
exit 1
Expand Down
101 changes: 100 additions & 1 deletion .github/scripts/publish-local-plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,19 @@ case "\${1:-}" in
view)
if [ "\${3:-}" = "dist-tags" ]; then
increment_counter dist_tag >/dev/null
printf '{"%s":"%s"}\n' "\${NPM_DIST_TAG}" "\${NPM_MOCK_DIST_TAG_VERSION:-\${RELEASE_VERSION}}"
if [ "\${NPM_MOCK_SCENARIO}" = "dist-tag-lookup-fails" ]; then
echo "npm error code E500" >&2
exit 1
fi
if [ "\${NPM_MOCK_DIST_TAGS_EMPTY:-false}" = "true" ]; then
printf '{}\n'
exit 0
elif [ "\${NPM_MOCK_SCENARIO}" = "already-visible" ]; then
dist_tag_version="\${NPM_MOCK_DIST_TAG_VERSION:-\${RELEASE_VERSION}}"
else
dist_tag_version="\${NPM_MOCK_PREFLIGHT_DIST_TAG_VERSION:-2.0.11}"
fi
printf '{"%s":"%s"}\n' "\${NPM_DIST_TAG}" "\${dist_tag_version}"
exit 0
fi
view_count="$(increment_counter view)"
Expand Down Expand Up @@ -138,6 +150,10 @@ echo "Unexpected git command: $*" >&2
exit 2
`;

const mockSleep = `#!/usr/bin/env bash
exit 0
`;

function readCounter(stateDirectory, name) {
try {
return Number(readFileSync(join(stateDirectory, name), "utf8"));
Expand All @@ -156,13 +172,16 @@ function runScenario(scenario, overrides = {}) {
const npmPath = join(binDirectory, "npm");
const nodePath = join(binDirectory, "node");
const gitPath = join(binDirectory, "git");
const sleepPath = join(binDirectory, "sleep");
const releaseTarball = join(fixtureDirectory, "release.tgz");
writeFileSync(npmPath, mockNpm, "utf8");
chmodSync(npmPath, 0o755);
writeFileSync(nodePath, mockNode, "utf8");
chmodSync(nodePath, 0o755);
writeFileSync(gitPath, mockGit, "utf8");
chmodSync(gitPath, 0o755);
writeFileSync(sleepPath, mockSleep, "utf8");
chmodSync(sleepPath, 0o755);
const localPackRoot = join(fixtureDirectory, "local-pack-root");
mkdirSync(join(localPackRoot, "package", "adapters", "hermes"), { recursive: true });
writeFileSync(
Expand Down Expand Up @@ -211,6 +230,7 @@ function runScenario(scenario, overrides = {}) {
whoamiCount: readCounter(stateDirectory, "whoami"),
packCount: readCounter(stateDirectory, "pack"),
metadataWaitCount: readCounter(stateDirectory, "metadata_wait"),
distTagCount: readCounter(stateDirectory, "dist_tag"),
publishedArgument: (() => {
try {
return readFileSync(join(stateDirectory, "published-argument"), "utf8");
Expand Down Expand Up @@ -244,6 +264,64 @@ test("publishes once and continues only after bounded registry verification", ()
assert.match(result.stdout, /bounded registry visibility check both succeeded/);
});

test("fails before authentication when a newer release already owns the npm channel", () => {
const result = runScenario("eventually-visible", {
NPM_MOCK_PREFLIGHT_DIST_TAG_VERSION: "2.0.13",
});

assert.notEqual(result.status, 0);
assert.equal(result.distTagCount, 1);
assert.equal(result.whoamiCount, 0);
assert.equal(result.publishCount, 0);
assert.equal(result.metadataWaitCount, 0);
assert.match(result.stdout + result.stderr, /Refusing to move npm dist-tag latest backwards from 2\.0\.13 to 2\.0\.12/);
});

test("uses SemVer precedence instead of lexical order for prerelease channels", () => {
const result = runScenario("eventually-visible", {
RELEASE_VERSION: "2.0.12-beta.9",
RELEASE_TAG: "memos-local-plugin-v2.0.12-beta.9",
NPM_DIST_TAG: "beta",
NPM_MOCK_PREFLIGHT_DIST_TAG_VERSION: "2.0.12-beta.10",
});

assert.notEqual(result.status, 0);
assert.equal(result.whoamiCount, 0);
assert.equal(result.publishCount, 0);
assert.match(result.stdout + result.stderr, /Refusing to move npm dist-tag beta backwards from 2\.0\.12-beta\.10 to 2\.0\.12-beta\.9/);
});

test("fails closed when npm version and dist-tag metadata contradict each other", () => {
const result = runScenario("eventually-visible", {
NPM_MOCK_PREFLIGHT_DIST_TAG_VERSION: "2.0.12",
});

assert.notEqual(result.status, 0);
assert.equal(result.whoamiCount, 0);
assert.equal(result.publishCount, 0);
assert.match(result.stdout + result.stderr, /reports that version as absent/);
});

test("fails before authentication when npm channel state cannot be inspected", () => {
const result = runScenario("dist-tag-lookup-fails");

assert.notEqual(result.status, 0);
assert.equal(result.distTagCount, 3);
assert.equal(result.whoamiCount, 0);
assert.equal(result.publishCount, 0);
assert.match(result.stdout + result.stderr, /refusing to publish without a channel monotonicity check/);
});

test("allows initializing an npm channel that does not exist yet", () => {
const result = runScenario("eventually-visible", {
NPM_MOCK_DIST_TAGS_EMPTY: "true",
});

assert.equal(result.status, 0, result.stderr);
assert.equal(result.publishCount, 1);
assert.match(result.stdout, /dist-tag latest is not set/);
});

test("stops before tag creation when publish succeeds but visibility remains delayed", () => {
const result = runScenario("always-missing");

Expand All @@ -254,6 +332,27 @@ test("stops before tag creation when publish succeeds but visibility remains del
assert.match(result.stdout + result.stderr, /Refusing to issue a second publish request/);
});

test("allows npm publish after a staged paired Draft Release only in npm-only phase", () => {
const blocked = runScenario("eventually-visible", {
RELEASE_METADATA_STATE: "complete",
});

assert.notEqual(blocked.status, 0);
assert.equal(blocked.publishCount, 0);
assert.match(blocked.stdout + blocked.stderr, /Refusing to publish after tag metadata already exists/);

const allowed = runScenario("eventually-visible", {
RELEASE_METADATA_STATE: "complete",
ALLOW_STAGED_TAG_BEFORE_NPM: "true",
});

assert.equal(allowed.status, 0, allowed.stderr);
assert.equal(allowed.publishCount, 1);
assert.equal(allowed.metadataWaitCount, 1);
assert.equal(allowed.packCount, 1);
assert.match(allowed.stdout, /publishing npm after a staged paired local-plugin Draft Release/);
});

test("fails when publish fails and the requested version remains absent", () => {
const result = runScenario("publish-fails");

Expand Down
23 changes: 20 additions & 3 deletions .github/scripts/publish-paired-local-plugin-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ function output(values) {
appendFileSync(outputFile, `${lines}\n`, "utf8");
}

function outputForIntent(status, memosRelease, intent) {
output({
status,
memos_release_tag: memosRelease.tag,
memos_release_version: memosRelease.tag.replace(/^v/, ""),
local_plugin_tag: intent?.tag || "",
local_plugin_version: intent?.version?.replace(/^v/, "") || "",
local_plugin_source_sha: intent?.source_sha || "",
});
}

function normalizeRelease(raw) {
return {
id: Number(raw?.id || 0),
Expand Down Expand Up @@ -155,11 +166,12 @@ function loadMemOSRelease(repo) {
export function main() {
const repo = String(process.env.GITHUB_REPOSITORY || "").trim();
if (repo !== "MemTensor/MemOS") fail(`paired publisher is restricted to MemTensor/MemOS; received ${repo || "<empty>"}`);
const validateOnly = String(process.env.VALIDATE_ONLY || "").trim() === "true";
const memosRelease = loadMemOSRelease(repo);
const intent = parseLocalPluginReleaseIntent(memosRelease.body);
if (!intent.enabled) {
validatePair({ memosRelease, pluginRelease: null, pluginTagSha: "" });
output({ status: "skipped", memos_release_tag: memosRelease.tag, local_plugin_tag: "" });
outputForIntent("skipped", memosRelease, intent);
console.log(`MemOS Release ${memosRelease.tag} has no paired local-plugin publish; nothing to do.`);
return;
}
Expand All @@ -178,10 +190,15 @@ export function main() {
memosTagSha: String(memosTagCommit.sha || ""),
});
if (validated.alreadyPublished) {
output({ status: "already_published", memos_release_tag: memosRelease.tag, local_plugin_tag: intent.tag });
outputForIntent("already_published", memosRelease, intent);
console.log(`Paired local-plugin GitHub Release ${intent.tag} is already published and matches the MemOS intent.`);
return;
}
if (validateOnly) {
outputForIntent("staged", memosRelease, intent);
console.log(`Paired local-plugin GitHub Release ${intent.tag} is staged and ready for npm publish.`);
return;
}

ghJson([
"api",
Expand All @@ -204,7 +221,7 @@ export function main() {
memosTagSha: String(memosTagCommit.sha || ""),
});
if (!after.alreadyPublished) fail(`paired local-plugin GitHub Release ${intent.tag} remained a Draft after publish`);
output({ status: "published", memos_release_tag: memosRelease.tag, local_plugin_tag: intent.tag });
outputForIntent("published", memosRelease, intent);
console.log(`Published paired local-plugin GitHub Release ${intent.tag}; its release.published webhook is the docs trigger.`);
}

Expand Down
Loading
Loading