mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(release): gate Snap publish on public source release
This commit is contained in:
1 parent
6c74bf2743
commit
0865fdf464
6 files changed
+453
-34
No files matched your search
@@ -1318,31 +1318,3 @@ jobs:
|
||||
artifacts/windows-artifacts/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-snap:
|
||||
name: Publish to Snapcraft Store
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
|
||||
|
||||
steps:
|
||||
- name: Download snap artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-portable-artifacts
|
||||
path: artifacts
|
||||
|
||||
- name: Setup Snapcraft
|
||||
uses: samuelmeuli/action-snapcraft@v3
|
||||
|
||||
- name: Publish all snaps to edge channel
|
||||
run: |
|
||||
# Find and publish all snap files
|
||||
for SNAP_FILE in artifacts/*.snap; do
|
||||
if [ -f "$SNAP_FILE" ]; then
|
||||
echo "Publishing: $SNAP_FILE"
|
||||
snapcraft upload --release=edge "$SNAP_FILE"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Publish Snap after public release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-snap:
|
||||
name: Publish public-release Snap to edge
|
||||
if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
|
||||
SOURCE_ARCHIVE_NAME: linux-frame-copy-runtime-sources.tar.xz
|
||||
|
||||
steps:
|
||||
- name: Checkout released tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Select exact public release assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh api \
|
||||
--paginate \
|
||||
--slurp \
|
||||
"repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \
|
||||
> "${RUNNER_TEMP}/snap-release-assets.json"
|
||||
node tools/packaging/release-snap-assets.cjs select \
|
||||
--assets-json "${RUNNER_TEMP}/snap-release-assets.json" \
|
||||
--output-json "${RUNNER_TEMP}/selected-snap-release-assets.json"
|
||||
|
||||
- name: Download exact public release assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ASSET_DIRECTORY="${RUNNER_TEMP}/snap-release-downloads"
|
||||
rm -rf "${ASSET_DIRECTORY}"
|
||||
mkdir -p "${ASSET_DIRECTORY}"
|
||||
node -e \
|
||||
"const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of [...selected.snapAssets, selected.sourceAsset]) console.log([asset.id, asset.name].join('\\t'));" \
|
||||
"${RUNNER_TEMP}/selected-snap-release-assets.json" |
|
||||
while IFS=$'\t' read -r ASSET_ID ASSET_NAME; do
|
||||
gh api \
|
||||
--header "Accept: application/octet-stream" \
|
||||
"repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" \
|
||||
> "${ASSET_DIRECTORY}/${ASSET_NAME}"
|
||||
done
|
||||
|
||||
- name: Verify downloaded public release assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
test -s "${RUNNER_TEMP}/snap-release-downloads/${SOURCE_ARCHIVE_NAME}"
|
||||
node tools/packaging/release-snap-assets.cjs verify \
|
||||
--manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
|
||||
--directory "${RUNNER_TEMP}/snap-release-downloads"
|
||||
|
||||
- name: Setup Snapcraft
|
||||
uses: samuelmeuli/action-snapcraft@v3
|
||||
|
||||
- name: Publish all public-release snaps to edge
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
node -e \
|
||||
"const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of selected.snapAssets) console.log(asset.name);" \
|
||||
"${RUNNER_TEMP}/selected-snap-release-assets.json" |
|
||||
while IFS= read -r SNAP_NAME; do
|
||||
SNAP_FILE="${RUNNER_TEMP}/snap-release-downloads/${SNAP_NAME}"
|
||||
echo "Publishing public release asset: ${SNAP_NAME}"
|
||||
snapcraft upload --release=edge "${SNAP_FILE}"
|
||||
done
|
||||
@@ -310,7 +310,7 @@ test('dedicated packaged x64 smoke cannot silently skip', () => {
|
||||
assert.doesNotMatch(hardwareDiagnostic, /LIBGL_ALWAYS_SOFTWARE/);
|
||||
});
|
||||
|
||||
test('release and Snap publication consume split Linux artifacts and source compliance', () => {
|
||||
test('draft release consumes split Linux artifacts and source compliance', () => {
|
||||
for (const artifactName of [
|
||||
'linux-system-artifacts',
|
||||
'linux-portable-artifacts',
|
||||
@@ -357,8 +357,5 @@ test('release and Snap publication consume split Linux artifacts and source comp
|
||||
]) {
|
||||
assert.ok(release.includes(releasePath));
|
||||
}
|
||||
assert.match(
|
||||
workflowStep('Download snap artifact'),
|
||||
/name: linux-portable-artifacts/
|
||||
);
|
||||
assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m);
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
"{workspaceRoot}/package.json",
|
||||
"{workspaceRoot}/electron-builder.json",
|
||||
"{workspaceRoot}/.github/workflows/build-and-make.yaml",
|
||||
"{workspaceRoot}/.github/workflows/publish-snap.yaml",
|
||||
"{workspaceRoot}/apps/electron-backend/build-embedded-mpv.js",
|
||||
"{workspaceRoot}/apps/electron-backend/project.json",
|
||||
"{workspaceRoot}/apps/electron-backend/native/src/embedded_mpv_win32.cc",
|
||||
@@ -25,6 +26,8 @@
|
||||
"{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs",
|
||||
"{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs",
|
||||
"{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs",
|
||||
"{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs",
|
||||
"{workspaceRoot}/tools/packaging/release-snap-assets.cjs",
|
||||
"{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.mjs",
|
||||
"{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.test.mjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs",
|
||||
@@ -38,11 +41,17 @@
|
||||
"{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs"
|
||||
],
|
||||
"options": {
|
||||
"command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs",
|
||||
"command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs",
|
||||
"cwd": "{workspaceRoot}"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/.github/workflows/publish-snap.yaml",
|
||||
"{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs",
|
||||
"{workspaceRoot}/tools/packaging/release-snap-assets.cjs"
|
||||
],
|
||||
"command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const workspaceRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..'
|
||||
);
|
||||
const buildWorkflowPath = path.join(
|
||||
workspaceRoot,
|
||||
'.github',
|
||||
'workflows',
|
||||
'build-and-make.yaml'
|
||||
);
|
||||
const publishWorkflowPath = path.join(
|
||||
workspaceRoot,
|
||||
'.github',
|
||||
'workflows',
|
||||
'publish-snap.yaml'
|
||||
);
|
||||
const releaseAssetHelperPath = path.join(
|
||||
workspaceRoot,
|
||||
'tools',
|
||||
'packaging',
|
||||
'release-snap-assets.cjs'
|
||||
);
|
||||
|
||||
async function loadReleaseAssetHelper() {
|
||||
if (!fs.existsSync(releaseAssetHelperPath)) {
|
||||
return null;
|
||||
}
|
||||
return import(pathToFileURL(releaseAssetHelperPath).href);
|
||||
}
|
||||
|
||||
test('publishes Snap only after a public v-tag release contains binary and source assets', () => {
|
||||
assert.equal(
|
||||
fs.existsSync(publishWorkflowPath),
|
||||
true,
|
||||
'the release-published Snap workflow must exist'
|
||||
);
|
||||
const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8');
|
||||
assert.match(
|
||||
workflowText,
|
||||
/^on:\n {4}release:\n {8}types:\n {12}- published$/m
|
||||
);
|
||||
assert.match(workflowText, /^permissions:\n {4}contents: read$/m);
|
||||
assert.match(
|
||||
workflowText,
|
||||
/startsWith\(github\.event\.release\.tag_name,\s*'v'\)/
|
||||
);
|
||||
assert.match(workflowText, /github\.event\.release\.draft\s*==\s*false/);
|
||||
|
||||
const validateIndex = workflowText.indexOf(
|
||||
'- name: Select exact public release assets'
|
||||
);
|
||||
const verifyIndex = workflowText.indexOf(
|
||||
'- name: Verify downloaded public release assets'
|
||||
);
|
||||
const uploadIndex = workflowText.indexOf(
|
||||
'- name: Publish all public-release snaps to edge'
|
||||
);
|
||||
assert.ok(validateIndex >= 0);
|
||||
assert.ok(verifyIndex > validateIndex);
|
||||
assert.ok(uploadIndex > verifyIndex);
|
||||
|
||||
assert.match(
|
||||
workflowText,
|
||||
/github\.event\.release\.id[\s\S]*release-snap-assets\.cjs select/
|
||||
);
|
||||
assert.match(workflowText, /linux-frame-copy-runtime-sources\.tar\.xz/);
|
||||
assert.match(workflowText, /release-snap-assets\.cjs verify/);
|
||||
assert.match(workflowText, /snapcraft upload --release=edge/);
|
||||
|
||||
const buildWorkflow = fs.readFileSync(buildWorkflowPath, 'utf8');
|
||||
assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m);
|
||||
assert.doesNotMatch(buildWorkflow, /snapcraft upload/);
|
||||
});
|
||||
|
||||
test('selects every exact Snap and exactly one compliance source asset', async () => {
|
||||
const helper = await loadReleaseAssetHelper();
|
||||
assert.ok(helper, 'the release asset selection helper must exist');
|
||||
const selected = helper.selectSnapReleaseAssets([
|
||||
{ id: 9, name: 'IPTVnator-1.0.0-amd64.snap' },
|
||||
{ id: 3, name: 'linux-frame-copy-runtime-sources.tar.xz' },
|
||||
{ id: 8, name: 'IPTVnator-1.0.0-armhf.snap' },
|
||||
{ id: 7, name: 'IPTVnator-1.0.0.AppImage' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(selected, {
|
||||
snapAssets: [
|
||||
{ id: 9, name: 'IPTVnator-1.0.0-amd64.snap' },
|
||||
{ id: 8, name: 'IPTVnator-1.0.0-armhf.snap' },
|
||||
],
|
||||
sourceAsset: {
|
||||
id: 3,
|
||||
name: 'linux-frame-copy-runtime-sources.tar.xz',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a release missing either exact asset class or containing ambiguous source assets', async () => {
|
||||
const helper = await loadReleaseAssetHelper();
|
||||
assert.ok(helper, 'the release asset selection helper must exist');
|
||||
assert.throws(
|
||||
() =>
|
||||
helper.selectSnapReleaseAssets([
|
||||
{
|
||||
id: 1,
|
||||
name: 'linux-frame-copy-runtime-sources.tar.xz',
|
||||
},
|
||||
]),
|
||||
/at least one \.snap asset/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
helper.selectSnapReleaseAssets([{ id: 1, name: 'IPTVnator.snap' }]),
|
||||
/exactly one linux-frame-copy-runtime-sources\.tar\.xz/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
helper.selectSnapReleaseAssets([
|
||||
{ id: 1, name: 'IPTVnator.snap' },
|
||||
{
|
||||
id: 2,
|
||||
name: 'linux-frame-copy-runtime-sources.tar.xz',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'linux-frame-copy-runtime-sources.tar.xz',
|
||||
},
|
||||
]),
|
||||
/exactly one linux-frame-copy-runtime-sources\.tar\.xz/
|
||||
);
|
||||
});
|
||||
|
||||
test('verifies the complete selected download set before publication', async (t) => {
|
||||
const helper = await loadReleaseAssetHelper();
|
||||
assert.ok(helper, 'the release asset selection helper must exist');
|
||||
const temporaryRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'iptvnator-snap-release-')
|
||||
);
|
||||
t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true }));
|
||||
const manifest = {
|
||||
snapAssets: [{ id: 1, name: 'IPTVnator.snap' }],
|
||||
sourceAsset: {
|
||||
id: 2,
|
||||
name: 'linux-frame-copy-runtime-sources.tar.xz',
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(temporaryRoot, 'IPTVnator.snap'), 'snap');
|
||||
assert.throws(
|
||||
() => helper.verifySnapReleaseDownloads(manifest, temporaryRoot),
|
||||
/missing or empty.*linux-frame-copy-runtime-sources\.tar\.xz/i
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryRoot, 'linux-frame-copy-runtime-sources.tar.xz'),
|
||||
'sources'
|
||||
);
|
||||
assert.deepEqual(
|
||||
helper.verifySnapReleaseDownloads(manifest, temporaryRoot),
|
||||
manifest
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { isDeepStrictEqual } = require('node:util');
|
||||
|
||||
const SOURCE_ARCHIVE_NAME = 'linux-frame-copy-runtime-sources.tar.xz';
|
||||
|
||||
function flattenAssetPages(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('GitHub release assets must be an array.');
|
||||
}
|
||||
return value.flatMap((entry) =>
|
||||
Array.isArray(entry) ? flattenAssetPages(entry) : [entry]
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeReleaseAsset(asset) {
|
||||
if (
|
||||
asset === null ||
|
||||
typeof asset !== 'object' ||
|
||||
!Number.isSafeInteger(asset.id) ||
|
||||
asset.id <= 0 ||
|
||||
typeof asset.name !== 'string' ||
|
||||
asset.name.length === 0 ||
|
||||
asset.name === '.' ||
|
||||
asset.name === '..' ||
|
||||
asset.name.includes('/') ||
|
||||
asset.name.includes('\\') ||
|
||||
[...asset.name].some((character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
})
|
||||
) {
|
||||
throw new Error('GitHub release contains an invalid asset record.');
|
||||
}
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
};
|
||||
}
|
||||
|
||||
function selectSnapReleaseAssets(assets) {
|
||||
const normalized = flattenAssetPages(assets).map(normalizeReleaseAsset);
|
||||
const snapAssets = normalized
|
||||
.filter(({ name }) => name.endsWith('.snap'))
|
||||
.sort(({ name: left }, { name: right }) => left.localeCompare(right));
|
||||
if (snapAssets.length === 0) {
|
||||
throw new Error(
|
||||
'Public release must contain at least one .snap asset.'
|
||||
);
|
||||
}
|
||||
|
||||
const sourceAssets = normalized.filter(
|
||||
({ name }) => name === SOURCE_ARCHIVE_NAME
|
||||
);
|
||||
if (sourceAssets.length !== 1) {
|
||||
throw new Error(
|
||||
`Public release must contain exactly one ${SOURCE_ARCHIVE_NAME} asset.`
|
||||
);
|
||||
}
|
||||
const names = normalized.map(({ name }) => name);
|
||||
if (new Set(names).size !== names.length) {
|
||||
throw new Error('GitHub release asset names must be unique.');
|
||||
}
|
||||
|
||||
return {
|
||||
snapAssets,
|
||||
sourceAsset: sourceAssets[0],
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalSelection(selection) {
|
||||
if (
|
||||
selection === null ||
|
||||
typeof selection !== 'object' ||
|
||||
!Array.isArray(selection.snapAssets)
|
||||
) {
|
||||
throw new Error('Invalid selected Snap release asset manifest.');
|
||||
}
|
||||
const canonical = selectSnapReleaseAssets([
|
||||
...selection.snapAssets,
|
||||
selection.sourceAsset,
|
||||
]);
|
||||
if (!isDeepStrictEqual(selection, canonical)) {
|
||||
throw new Error(
|
||||
'Selected Snap release asset manifest is not canonical.'
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function verifySnapReleaseDownloads(selection, directoryPath) {
|
||||
const canonical = canonicalSelection(selection);
|
||||
const expectedNames = [
|
||||
...canonical.snapAssets.map(({ name }) => name),
|
||||
canonical.sourceAsset.name,
|
||||
].sort();
|
||||
try {
|
||||
fs.accessSync(directoryPath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Missing public release asset directory: ${directoryPath}`
|
||||
);
|
||||
}
|
||||
for (const name of expectedNames) {
|
||||
const assetPath = path.join(directoryPath, name);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(assetPath);
|
||||
} catch {
|
||||
throw new Error(`Missing or empty public release asset: ${name}`);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) {
|
||||
throw new Error(`Missing or empty public release asset: ${name}`);
|
||||
}
|
||||
}
|
||||
const actualNames = fs.readdirSync(directoryPath).sort();
|
||||
if (!isDeepStrictEqual(actualNames, expectedNames)) {
|
||||
throw new Error(
|
||||
'Downloaded public release assets do not match the exact selected set.'
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const [command, ...tokens] = argv;
|
||||
const options = {};
|
||||
for (let index = 0; index < tokens.length; index += 2) {
|
||||
const name = tokens[index];
|
||||
const value = tokens[index + 1];
|
||||
if (!name?.startsWith('--') || value === undefined) {
|
||||
throw new Error(`Invalid command-line argument: ${name ?? ''}`);
|
||||
}
|
||||
options[name.slice(2)] = value;
|
||||
}
|
||||
return { command, options };
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const { command, options } = parseArguments(argv);
|
||||
if (
|
||||
command === 'select' &&
|
||||
options['assets-json'] &&
|
||||
options['output-json']
|
||||
) {
|
||||
writeJson(
|
||||
options['output-json'],
|
||||
selectSnapReleaseAssets(readJson(options['assets-json']))
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command === 'verify' && options.manifest && options.directory) {
|
||||
verifySnapReleaseDownloads(
|
||||
readJson(options.manifest),
|
||||
options.directory
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'Usage: release-snap-assets.cjs select --assets-json <path> --output-json <path> | verify --manifest <path> --directory <path>'
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SOURCE_ARCHIVE_NAME,
|
||||
selectSnapReleaseAssets,
|
||||
verifySnapReleaseDownloads,
|
||||
};
|
||||
Reference in new issue
Block a user