mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Bind release browser qualification to the narrow CI identity
Use a dedicated Auth Keys write client restricted to tag:ci-deploy, never the broader demo registration. Reject missing configuration and validate the actual running Self tag and DNS before Serve. Keep exact-source, denial-stop, no-replay and cleanup contracts, with connected offline registration and Serve controls. Change-source: pulse-maintainer
This commit is contained in:
@@ -371,6 +371,28 @@ non-mutating wrapper that verifies the current committed stable target. All
|
||||
stable demo writes require an exact stable tag and activation marker and run
|
||||
from `release-convergence.yml` under the global customer-promotion lease.
|
||||
|
||||
## Published-RC browser qualification registration
|
||||
|
||||
`qualify-browser-update-release.yml` must **not** reuse the demo binding above.
|
||||
It uses the separate repository secrets **TS_CI_DEPLOY_OAUTH_CLIENT_ID** and
|
||||
**TS_CI_DEPLOY_OAUTH_SECRET** for a dedicated business-tailnet OAuth client with
|
||||
only **Auth Keys write**, restricted to **tag:ci-deploy**. These names must not
|
||||
alias/copy the `tag:infra` demo client or a full-access administrative client.
|
||||
The Tailnet/credential owner verifies that client scope and tag restriction when
|
||||
configuring the pair; source inspection and the node's observed tag do not prove
|
||||
OAuth scope. No secret value belongs in source, logs or receipts.
|
||||
|
||||
The workflow fails before joining when either dedicated secret is unavailable;
|
||||
there is no demo-credential, auth-key or caller-input fallback. It checks the
|
||||
actual running node's **Self.Tags == ["tag:ci-deploy"]** and its own business
|
||||
DNS name before starting Serve. Only its own `127.0.0.1:7655` is served over
|
||||
HTTPS, with no peer selection, SSH, routes or private demo/production target.
|
||||
This journey needs no expansion of CI access to private services. Cleanup resets
|
||||
that new Serve route even if route setup fails after the successful join; the
|
||||
pinned Tailscale action and fresh hosted VM own node teardown. A configured
|
||||
binding, source tests or a joined node is not native update/recovery acceptance.
|
||||
|
||||
|
||||
## Helm CI
|
||||
|
||||
**File**: `helm-ci.yml`
|
||||
|
||||
@@ -112,21 +112,33 @@ jobs:
|
||||
npx --no-install playwright install --with-deps chromium
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends jq curl openssh-client
|
||||
- name: Register a fresh ephemeral project CI node
|
||||
- name: Require the dedicated narrow CI registration binding
|
||||
env:
|
||||
CI_CLIENT_ID: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}
|
||||
CI_CLIENT_SECRET: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$CI_CLIENT_ID" || -z "$CI_CLIENT_SECRET" ]]; then
|
||||
echo 'Dedicated tag:ci-deploy OAuth binding is not configured; no demo credential fallback is permitted.' >&2
|
||||
exit 2
|
||||
fi
|
||||
- name: Register a fresh ephemeral narrow CI node
|
||||
id: tailnet
|
||||
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:infra
|
||||
oauth-client-id: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}
|
||||
tags: tag:ci-deploy
|
||||
version: '1.94.2'
|
||||
- name: Serve only this disposable install
|
||||
id: serve
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Never enumerate peer/private targets or use a supplied hostname.
|
||||
origin=$(tailscale status --json | python3 -c 'import json,re,sys; n=json.load(sys.stdin)["Self"]["DNSName"].rstrip("."); assert re.fullmatch(r"[a-z0-9-]+\.tawny-powan\.ts\.net", n); print("https://"+n)')
|
||||
# Validate the actual Self tag before Serve. Never select a peer or
|
||||
# fall back to the demo client's broader tag:infra identity.
|
||||
origin=$(tailscale status --json | python3 scripts/release_browser_tailnet.py)
|
||||
tailscale serve --bg --https=443 http://127.0.0.1:7655
|
||||
echo "origin=$origin" >> "$GITHUB_OUTPUT"
|
||||
python3 scripts/write_github_output.py origin "$origin"
|
||||
- name: Exercise native browser update and documented recovery
|
||||
env:
|
||||
FROM: ${{ steps.pair.outputs.from }}
|
||||
@@ -142,7 +154,7 @@ jobs:
|
||||
PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/browser-release/browsers" \
|
||||
bash scripts/release_lifecycle_rehearsal.sh --hosted-browser --from "$FROM" --to "$TAG"
|
||||
- name: Close ephemeral Serve route
|
||||
if: always() && steps.serve.outcome == 'success'
|
||||
if: always() && steps.tailnet.outcome == 'success'
|
||||
run: tailscale serve reset
|
||||
- name: Retain bounded identity and terminal receipts, never credentials
|
||||
if: always()
|
||||
|
||||
@@ -6441,10 +6441,20 @@ collision-safe writer. Each installed binary hash must match its
|
||||
signed archive, rather than just a reported version. No rebuilt main binary,
|
||||
frozen-product insertion, candidate re-cut or stable promotion is performed.
|
||||
|
||||
A pinned Tailscale action registers an ephemeral project CI node; Serve exposes
|
||||
only its own loopback Pulse install through its derived `tawny-powan.ts.net`
|
||||
HTTPS identity. No caller-selected endpoint, peer or previous stopped target is
|
||||
used. Registration alone receives the existing CI OAuth credential; acceptance
|
||||
A pinned Tailscale action registers an ephemeral project CI node using only
|
||||
`tag:ci-deploy` and the dedicated `TS_CI_DEPLOY_OAUTH_CLIENT_ID` /
|
||||
`TS_CI_DEPLOY_OAUTH_SECRET` binding. Its OAuth client has only Auth Keys write,
|
||||
restricted to that tag; it must not alias the `tag:infra` demo or full-access
|
||||
administrative identity. The credential owner verifies that configured scope;
|
||||
source and Self observations are not proof of OAuth/ACL authority. Missing
|
||||
dedicated registration configuration stops before join, with no credential,
|
||||
auth-key or caller-input fallback. `release_browser_tailnet.py` rejects anything
|
||||
except a running, online Self node with exactly that one tag and its own valid
|
||||
business DNS name before Serve or origin output. No peers or raw status enter
|
||||
receipts. Serve exposes only its own loopback Pulse install through that derived
|
||||
`tawny-powan.ts.net` HTTPS identity; no private-service ACL expansion is needed.
|
||||
No caller-selected endpoint, peer or previous stopped target is used. Only the
|
||||
fixed prerequisite/registration steps receive the dedicated OAuth pair; acceptance
|
||||
commands receive a scrubbed environment with no provider, production or
|
||||
publishing credential. State is synthetic, disabled resources and throwaway
|
||||
admin/API authentication, with the existing telemetry opt-out.
|
||||
@@ -6473,7 +6483,8 @@ original auto-update/timer intent after forward update and documented CLI
|
||||
then checks the restored baseline. This is CLI recovery plus browser readback,
|
||||
**not** the separate history-based UI rollback action. Cleanup stops the
|
||||
throwaway service/timer, records its result and fails the process on failure;
|
||||
the workflow resets the new Serve route and its hosted VM is disposed of.
|
||||
the workflow resets the new Serve route after any successful join, including
|
||||
partial route setup failure, and its hosted VM is disposed of.
|
||||
|
||||
Bounded control, signed packet, installer/binary, phase, browser, cleanup and
|
||||
redacted settings receipts are retained for seven days. Successful screenshots
|
||||
@@ -6488,6 +6499,11 @@ Verification: `scripts/tests/test_release_browser_packet.py` executes immutable
|
||||
release/asset/binary identity rejection, worker-mode exclusion, scrubbed command
|
||||
environment and cleanup exit controls. `test_release_lifecycle_rehearsal.py`
|
||||
executes the recovery-access stop before another browser process can start.
|
||||
`scripts/tests/test_release_browser_tailnet.py` executes the actual prerequisite
|
||||
and Serve shell with fixed offline node observations: absent dedicated
|
||||
configuration cannot borrow the demo credential, broad/extra/missing tags and
|
||||
foreign/unsafe Self identities cannot start Serve or write an origin. Its binding
|
||||
controls reject broad demo secret reuse and caller-selected registration inputs.
|
||||
`tests/integration/scripts/release-browser-journey.test.cjs` exercises the real
|
||||
observer against offline Chromium success/recovery, anonymous auth probes,
|
||||
restart transport gaps, wrong-target, silent-SSE and stuck-modal fixtures, plus
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate only the joined CI node's identity before exposing its loopback UI.
|
||||
|
||||
Reads local `tailscale status --json`; makes no network request and never emits
|
||||
peer inventory, raw status or credentials. OAuth/ACL scope is configured by the
|
||||
Tailnet owner, not inferred from a DNS name or a tag observation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
MAX_STATUS_BYTES = 1024 * 1024
|
||||
ERROR = "Release browser requires a running Self node with only tag:ci-deploy and a business-tailnet DNS name"
|
||||
|
||||
|
||||
def self_origin(status):
|
||||
if not isinstance(status, dict) or status.get("BackendState") != "Running":
|
||||
raise ValueError(ERROR)
|
||||
node = status.get("Self")
|
||||
if (not isinstance(node, dict) or node.get("Online") is not True
|
||||
or node.get("Tags") != ["tag:ci-deploy"]):
|
||||
raise ValueError(ERROR)
|
||||
name = node.get("DNSName")
|
||||
if not isinstance(name, str):
|
||||
raise ValueError(ERROR)
|
||||
name = name.removesuffix(".")
|
||||
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.tawny-powan\.ts\.net", name):
|
||||
raise ValueError(ERROR)
|
||||
return "https://" + name
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
raw = sys.stdin.buffer.read(MAX_STATUS_BYTES + 1)
|
||||
if len(raw) > MAX_STATUS_BYTES:
|
||||
raise ValueError(ERROR)
|
||||
origin = self_origin(json.loads(raw))
|
||||
except (ValueError, UnicodeError):
|
||||
print(ERROR, file=sys.stderr)
|
||||
return 2
|
||||
print(origin)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -138,10 +138,13 @@ class BrowserPacketTest(unittest.TestCase):
|
||||
function = source.split('cexec() {', 1)[1].split('\nprint_diagnostics()', 1)[0]
|
||||
script = 'HOSTED_BROWSER=true\ncexec() {' + function + '\ncexec env EXTRA=fixture\n'
|
||||
result = subprocess.run(['bash', '-c', script], env={'PATH': '/usr/bin:/bin',
|
||||
'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret'},
|
||||
'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret',
|
||||
'TS_CI_DEPLOY_OAUTH_CLIENT_ID': 'synthetic-narrow-id',
|
||||
'TS_CI_DEPLOY_OAUTH_SECRET': 'synthetic-narrow-secret'},
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertNotIn('synthetic-secret', result.stdout)
|
||||
self.assertNotIn('synthetic-narrow', result.stdout)
|
||||
self.assertNotIn('PULSE_INSTALL_ALLOW_DOCKER', result.stdout)
|
||||
self.assertIn('EXTRA=fixture', result.stdout)
|
||||
|
||||
@@ -152,11 +155,12 @@ class BrowserPacketTest(unittest.TestCase):
|
||||
self.assertNotIn('ignoreHTTPSErrors', (ROOT / 'tests/integration/scripts/release-browser-journey.cjs').read_text())
|
||||
self.assertIn('test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"', source)
|
||||
self.assertIn('runs-on: ubuntu-24.04', source)
|
||||
self.assertIn('"Self"]["DNSName"]', source)
|
||||
self.assertIn('tailscale status --json | python3 scripts/release_browser_tailnet.py', source)
|
||||
self.assertIn('ssh-keygen -Y verify', source)
|
||||
acceptance = source.split('- name: Exercise native browser update', 1)[1].split('- name: Close', 1)[0]
|
||||
self.assertIn('sudo env -i', acceptance)
|
||||
for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'):
|
||||
for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'TS_CI_DEPLOY_OAUTH_CLIENT_ID',
|
||||
'TS_CI_DEPLOY_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'):
|
||||
self.assertNotIn(name, acceptance)
|
||||
artifact = source.split('- name: Retain bounded', 1)[1]
|
||||
for private in ('browser-auth.json', 'private-install.log', 'private-updater.log', 'datadir.'):
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Offline controls for the actual browser workflow's narrow registration route."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = ROOT / "scripts/release_browser_tailnet.py"
|
||||
WORKFLOW = ROOT / ".github/workflows/qualify-browser-update-release.yml"
|
||||
SPEC = importlib.util.spec_from_file_location("release_browser_tailnet", HELPER)
|
||||
tailnet = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(tailnet)
|
||||
|
||||
|
||||
def status():
|
||||
return {"BackendState": "Running", "Self": {"Online": True,
|
||||
"Tags": ["tag:ci-deploy"], "DNSName": "ci-browser.tawny-powan.ts.net."},
|
||||
"Peer": {"private": {"DNSName": "private-peer", "private_data": "not-for-receipts"}}}
|
||||
|
||||
|
||||
def workflow_step(name):
|
||||
step = WORKFLOW.read_text().split(" - name: " + name + "\n", 1)[1].split(" - name:", 1)[0]
|
||||
return textwrap.dedent(step.split(" run: |\n", 1)[1])
|
||||
|
||||
|
||||
def assert_registration_binding(case, source):
|
||||
# Closed, literal action inputs: no demo fallback or caller-selected tag/key.
|
||||
action = source.split("uses: tailscale/github-action@", 1)[1].split(" - name:", 1)[0]
|
||||
expected = {
|
||||
"oauth-client-id": "${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}",
|
||||
"oauth-secret": "${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}",
|
||||
"tags": "tag:ci-deploy",
|
||||
"version": "'1.94.2'",
|
||||
}
|
||||
actual = dict(line.strip().split(": ", 1) for line in action.split(" with:\n", 1)[1].splitlines() if line.strip())
|
||||
case.assertEqual(expected, actual)
|
||||
for broad in ("secrets.TS_OAUTH_CLIENT_ID", "secrets.TS_OAUTH_SECRET", "tags: tag:infra"):
|
||||
case.assertNotIn(broad, source)
|
||||
|
||||
|
||||
class SelfIdentityTest(unittest.TestCase):
|
||||
def test_reads_only_the_running_narrow_self_identity(self):
|
||||
self.assertEqual("https://ci-browser.tawny-powan.ts.net", tailnet.self_origin(status()))
|
||||
observed = status()
|
||||
observed["Peer"] = "untrusted and deliberately not a peer inventory"
|
||||
self.assertEqual("https://ci-browser.tawny-powan.ts.net", tailnet.self_origin(observed))
|
||||
observed["Self"]["DNSName"] = "a.tawny-powan.ts.net"
|
||||
self.assertEqual("https://a.tawny-powan.ts.net", tailnet.self_origin(observed))
|
||||
|
||||
def test_rejects_broad_extra_missing_and_malformed_tags(self):
|
||||
for tags in (["tag:infra"], ["tag:ci-deploy", "tag:infra"], [], None,
|
||||
"tag:ci-deploy", ["tag:ci-deploy", "tag:ci-deploy"], ["tag:production"]):
|
||||
with self.subTest(tags=tags), self.assertRaisesRegex(ValueError, "only tag:ci-deploy"):
|
||||
observed = status()
|
||||
observed["Self"]["Tags"] = tags
|
||||
tailnet.self_origin(observed)
|
||||
|
||||
def test_rejects_non_self_wrong_tailnet_and_unsafe_dns(self):
|
||||
for name in (None, 1, "private-peer", "ci.other.ts.net", "https://ci.tawny-powan.ts.net",
|
||||
"ci.tawny-powan.ts.net\nsecret", "ci.tawny-powan.ts.net..",
|
||||
"-ci.tawny-powan.ts.net", "ci-.tawny-powan.ts.net",
|
||||
"a" * 64 + ".tawny-powan.ts.net"):
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
observed = status()
|
||||
observed["Self"]["DNSName"] = name
|
||||
tailnet.self_origin(observed)
|
||||
for observed in (None, [], {}, status() | {"Self": []}, status() | {"BackendState": "NeedsLogin"}):
|
||||
with self.subTest(observed=observed), self.assertRaises(ValueError):
|
||||
tailnet.self_origin(observed)
|
||||
for online in (False, None, 1, "true"):
|
||||
with self.subTest(online=online), self.assertRaises(ValueError):
|
||||
observed = status()
|
||||
observed["Self"]["Online"] = online
|
||||
tailnet.self_origin(observed)
|
||||
|
||||
def test_cli_errors_never_return_raw_status_or_peers(self):
|
||||
invalid = status()
|
||||
invalid["Self"]["Tags"] = ["tag:infra"]
|
||||
for raw in (json.dumps(invalid).encode(), b"private diagnostic is not JSON", b"\xff",
|
||||
b" " * (tailnet.MAX_STATUS_BYTES + 1)):
|
||||
with self.subTest(bytes=len(raw)):
|
||||
result = subprocess.run(["python3", HELPER], input=raw, capture_output=True)
|
||||
self.assertEqual(2, result.returncode)
|
||||
self.assertEqual(b"", result.stdout)
|
||||
self.assertEqual((tailnet.ERROR + "\n").encode(), result.stderr)
|
||||
|
||||
|
||||
class WorkflowBindingTest(unittest.TestCase):
|
||||
def test_only_dedicated_tag_scoped_oauth_binding_is_declared(self):
|
||||
source = WORKFLOW.read_text()
|
||||
assert_registration_binding(self, source)
|
||||
self.assertIn("id: tailnet\n", source)
|
||||
self.assertIn("if: always() && steps.tailnet.outcome == 'success'", source)
|
||||
self.assertNotIn("steps.serve.outcome == 'success'", source)
|
||||
|
||||
def test_rejects_demo_credentials_and_broad_or_caller_selected_registration(self):
|
||||
source = WORKFLOW.read_text()
|
||||
for before, after in (
|
||||
("secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID", "secrets.TS_OAUTH_CLIENT_ID"),
|
||||
("secrets.TS_CI_DEPLOY_OAUTH_SECRET", "secrets.TS_OAUTH_SECRET"),
|
||||
("tags: tag:ci-deploy", "tags: tag:infra"),
|
||||
("tags: tag:ci-deploy", "tags: tag:ci-deploy,tag:infra"),
|
||||
("tags: tag:ci-deploy", "tags: ${{ inputs.tags }}"),
|
||||
("tags: tag:ci-deploy", "authkey: ${{ secrets.TS_OAUTH_SECRET }}"),
|
||||
):
|
||||
with self.subTest(after=after), self.assertRaises(AssertionError):
|
||||
assert_registration_binding(self, source.replace(before, after))
|
||||
|
||||
def test_missing_narrow_binding_stops_even_when_demo_credentials_exist(self):
|
||||
script = workflow_step("Require the dedicated narrow CI registration binding")
|
||||
for client_id, secret in (("", ""), ("present", ""), ("", "present"), ("present", "present")):
|
||||
env = {"PATH": os.environ["PATH"], "CI_CLIENT_ID": client_id, "CI_CLIENT_SECRET": secret,
|
||||
"TS_OAUTH_CLIENT_ID": "broad-id-not-to-be-used", "TS_OAUTH_SECRET": "broad-secret-not-to-be-used"}
|
||||
result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(0 if client_id and secret else 2, result.returncode)
|
||||
self.assertEqual("", result.stdout)
|
||||
self.assertNotIn("broad-id-not-to-be-used", result.stderr)
|
||||
self.assertNotIn("broad-secret-not-to-be-used", result.stderr)
|
||||
|
||||
def test_actual_serve_step_stops_before_serve_and_output_on_wrong_identity(self):
|
||||
script = workflow_step("Serve only this disposable install")
|
||||
fixtures = [status()]
|
||||
for tags in (["tag:infra"], ["tag:ci-deploy", "tag:infra"], []):
|
||||
observed = status()
|
||||
observed["Self"]["Tags"] = tags
|
||||
fixtures.append(observed)
|
||||
observed = status()
|
||||
observed["Self"]["DNSName"] = "private-peer.other.ts.net"
|
||||
fixtures.append(observed)
|
||||
fixtures.append({})
|
||||
for i, observed in enumerate(fixtures):
|
||||
with self.subTest(fixture=i), tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
(work / "status.json").write_text(json.dumps(observed))
|
||||
fake = work / "tailscale"
|
||||
fake.write_text('#!/bin/bash\nset -eu\n'
|
||||
'if [[ "$*" == "status --json" ]]; then cat "$FIXTURE/status.json"; '
|
||||
'else printf "%s\\n" "$*" >> "$FIXTURE/serve-calls"; fi\n')
|
||||
fake.chmod(0o700)
|
||||
env = {"PATH": str(work) + ":" + os.environ["PATH"], "FIXTURE": tmp,
|
||||
"GITHUB_OUTPUT": str(work / "output")}
|
||||
result = subprocess.run(["bash", "-c", script], cwd=ROOT, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(0 if i == 0 else 2, result.returncode, result.stderr)
|
||||
self.assertEqual("", result.stdout)
|
||||
self.assertNotIn("private-peer", result.stderr)
|
||||
self.assertNotIn("not-for-receipts", result.stderr)
|
||||
if i == 0:
|
||||
self.assertEqual("serve --bg --https=443 http://127.0.0.1:7655\n", (work / "serve-calls").read_text())
|
||||
header, value, delimiter = (work / "output").read_text().splitlines()
|
||||
self.assertEqual("origin<<" + delimiter, header)
|
||||
self.assertTrue(delimiter.startswith("pulse_output_"))
|
||||
self.assertEqual("https://ci-browser.tawny-powan.ts.net", value)
|
||||
else:
|
||||
self.assertFalse((work / "serve-calls").exists())
|
||||
self.assertFalse((work / "output").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user