From aab93cd8e65ef6637cbd902caea3c463cc19a67e Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 31 Oct 2026 19:12:46 +0100 Subject: [PATCH 1/2] Improved promise and error handling --- CHANGELOG.md | 10 ++ src/AngleSharp.Js.Docs/package.json | 2 +- src/AngleSharp.Js.Tests/OverloadTests.cs | 14 +- src/AngleSharp.Js.Tests/PromiseTests.cs | 116 +++++++++++++ src/AngleSharp.Js.Tests/WindowApiTests.cs | 14 ++ .../Dom/PromiseRejectionEvent.cs | 39 +++++ src/AngleSharp.Js/EngineInstance.cs | 153 ++++++++++++++++++ src/AngleSharp.Js/Extensions/DomDelegates.cs | 10 +- .../Extensions/EngineExtensions.cs | 36 ++++- .../Extensions/JsValueExtensions.cs | 54 +++++++ src/Directory.Build.props | 2 +- 11 files changed, 442 insertions(+), 8 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/PromiseTests.cs create mode 100644 src/AngleSharp.Js/Dom/PromiseRejectionEvent.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d88e8..a6b0287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 1.1.4 + +Released on Sunday, November 1 2026. + +- Fixed callbacks getting their first parameter as `this` instead of as an argument (e.g. a `MutationObserver` callback got the observer instead of the records); only a leading window (timers, animation frames) is the receiver +- Fixed arrays (e.g. the records of a `MutationObserver`) not being JS arrays, so `for...of` failed with "The value is not iterable" +- Fixed members returning a task (e.g. `fetch`) giving an object without `then`; they return a promise settled on the event loop +- Fixed dictionaries (e.g. the parsed body of a JSON response) not being plain JS objects +- Added `unhandledrejection` (`PromiseRejectionEvent`, `window.onunhandledrejection`); a rejection nobody handles is reported as an error of the browsing context + # 1.1.3 Released on Friday, October 30 2026. diff --git a/src/AngleSharp.Js.Docs/package.json b/src/AngleSharp.Js.Docs/package.json index 820ca56..c7093cd 100644 --- a/src/AngleSharp.Js.Docs/package.json +++ b/src/AngleSharp.Js.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/js", - "version": "1.1.2", + "version": "1.1.4", "preview": true, "description": "The doclet for the AngleSharp.Js documentation.", "keywords": [ diff --git a/src/AngleSharp.Js.Tests/OverloadTests.cs b/src/AngleSharp.Js.Tests/OverloadTests.cs index fc0fbce..f216b96 100644 --- a/src/AngleSharp.Js.Tests/OverloadTests.cs +++ b/src/AngleSharp.Js.Tests/OverloadTests.cs @@ -4,7 +4,9 @@ namespace AngleSharp.Js.Tests using AngleSharp.Dom; using NUnit.Framework; using System; + using System.Collections.Generic; using System.Globalization; + using System.Linq; using System.Threading.Tasks; [DomName("ProbeMode")] @@ -22,6 +24,9 @@ public sealed class ProbeOptions [DomName("left")] public Double? Left { get; set; } + [DomName("headers")] + public Dictionary Headers { get; set; } + [DomName("mode")] public ProbeMode Mode { get; set; } = ProbeMode.Near; } @@ -45,7 +50,12 @@ public static String Probe(this IElement element, Double x, Double y) => [DomName("probe")] public static String Probe(this IElement element, ProbeOptions options) => - String.Format(CultureInfo.InvariantCulture, "options:{0},{1}", options.Left?.ToString(CultureInfo.InvariantCulture) ?? "-", options.Mode); + String.Format(CultureInfo.InvariantCulture, "options:{0},{1}", options.Left?.ToString(CultureInfo.InvariantCulture) ?? "-", options.Mode) + + (options.Headers is null ? "" : ",headers:" + String.Join("|", options.Headers.Select(header => header.Key + "=" + header.Value))); + + [DomName("probeRecord")] + public static String ProbeRecord(this IElement element, IDictionary record) => + String.Join("|", record.Select(entry => entry.Key + "=" + entry.Value.ToString(CultureInfo.InvariantCulture))); } /// @@ -67,6 +77,8 @@ public class OverloadTests [TestCase("probe(true)", "flag:True")] [TestCase("probe({ left: 5, mode: 'far' })", "options:5,Far")] [TestCase("probe({})", "options:-,Near")] + [TestCase("probe({ headers: { Accept: 'application/json', 'X-Count': 2 } })", "options:-,Near,headers:Accept=application/json|X-Count=2")] + [TestCase("probeRecord({ a: 1, b: '2.5' })", "a=1|b=2.5")] [TestCase("probe(undefined)", "none")] public async Task OverloadFittingTheArgumentsIsCalled(String call, String expected) { diff --git a/src/AngleSharp.Js.Tests/PromiseTests.cs b/src/AngleSharp.Js.Tests/PromiseTests.cs new file mode 100644 index 0000000..0381870 --- /dev/null +++ b/src/AngleSharp.Js.Tests/PromiseTests.cs @@ -0,0 +1,116 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using AngleSharp.Browser.Dom.Events; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + [DomExposed("Window")] + public static class DeferredWindowExtensions + { + [DomName("later")] + public static async Task Later(this IWindow window, String value) + { + await Task.Delay(20).ConfigureAwait(false); + return value; + } + + [DomName("laterNode")] + public static async Task LaterNode(this IWindow window, String id) + { + await Task.Delay(20).ConfigureAwait(false); + return window.Document.GetElementById(id); + } + + [DomName("laterJson")] + public static async Task LaterJson(this IWindow window) + { + await Task.Delay(20).ConfigureAwait(false); + return new Dictionary + { + ["token"] = "abc", + ["user"] = null, + ["tags"] = new Object[] { "a", 2.0, new Dictionary { ["x"] = true } }, + }; + } + + [DomName("pause")] + public static Task Pause(this IWindow window) => Task.Delay(20); + + [DomName("fail")] + public static async Task Fail(this IWindow window, String message) + { + await Task.Delay(20).ConfigureAwait(false); + throw new InvalidOperationException(message); + } + } + + /// + /// Registered so that this assembly counts as a library the engine exposes. + /// + sealed class DeferredService + { + } + + /// + /// DOM members returning a task (e.g. fetch) give a promise to script, which settles with + /// the result of the task once it is done. + /// + [TestFixture] + public class PromiseTests + { + [TestCase("later('done').then(function (v) { document.title = v; })", "done")] + [TestCase("later('x').then(function (v) { return v + 'y'; }).then(function (v) { document.title = v; })", "xy")] + [TestCase("(async function () { document.title = await later('awaited'); })()", "awaited")] + [TestCase("document.title = later('a') instanceof Promise", "true")] + [TestCase("laterNode('d').then(function (e) { document.title = e === document.getElementById('d'); })", "true")] + [TestCase("laterJson().then(function (j) { document.title = [j.token, j.user, Array.isArray(j.tags), j.tags[1] + 1, j.tags[2].x, Object.keys(j).join('|')].join(); })", "abc,,true,3,true,token|user|tags")] + [TestCase("pause().then(function (v) { document.title = 'paused:' + v; })", "paused:undefined")] + [TestCase("fail('broken').catch(function (e) { document.title = (e instanceof Error) + ':' + e.message; })", "true:broken")] + public async Task TaskBecomesAPromise(String script, String expected) + { + var cfg = Configuration.Default.WithJs().WithEventLoop().With(new DeferredService()); + var document = await BrowsingContext.New(cfg).OpenAsync(m => m.Content($"
")); + + for (var i = 0; i < 100 && document.Title != expected; i++) + { + await Task.Delay(20); + } + + Assert.AreEqual(expected, document.Title); + } + + [TestCase("Promise.reject(new Error('lost'))", "lost")] + [TestCase("fail('broken').then(function () {})", "broken")] + [TestCase("Promise.resolve().then(function () { throw new Error('thrown'); })", "thrown")] + [TestCase("var p = Promise.reject(new Error('late')); p.catch(function () {})", null)] + [TestCase("addEventListener('unhandledrejection', function (e) { document.title = e.reason.message; e.preventDefault(); }); Promise.reject(new Error('seen'))", null)] + [TestCase("window.onunhandledrejection = function (e) { document.title = e.reason.message; return false; }; Promise.reject(new Error('seen'))", null)] + public async Task UnhandledRejectionIsReported(String script, String expected) + { + var cfg = Configuration.Default.WithJs().WithEventLoop().With(new DeferredService()); + var context = BrowsingContext.New(cfg); + String reported = null; + context.AddEventListener("error", (_, ev) => + { + if (ev is TrackEvent { Error: Exception error }) + { + reported = error.Message; + } + }); + var document = await context.OpenAsync(m => m.Content($"")); + + for (var i = 0; i < 25 && reported == null && document.Title.Length == 0; i++) + { + await Task.Delay(20); + } + + await Task.Delay(100); + Assert.AreEqual(expected, reported); + Assert.AreEqual(expected == null && script.Contains("seen") ? "seen" : "", document.Title); + } + } +} diff --git a/src/AngleSharp.Js.Tests/WindowApiTests.cs b/src/AngleSharp.Js.Tests/WindowApiTests.cs index e2d901c..b889b73 100644 --- a/src/AngleSharp.Js.Tests/WindowApiTests.cs +++ b/src/AngleSharp.Js.Tests/WindowApiTests.cs @@ -134,5 +134,19 @@ public async Task QueuedMicrotaskRunsAfterTheScript() Assert.AreEqual("abc", document.Title); } + + [Test] + public async Task MutationObserverCallbackGetsTheRecordsAndTheObserver() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var document = await context.OpenAsync(m => m.Content("
")); + + for (var i = 0; i < 100 && document.Title.Length == 0; i++) + { + await Task.Delay(20); + } + + Assert.AreEqual("true,childList,true", document.Title); + } } } diff --git a/src/AngleSharp.Js/Dom/PromiseRejectionEvent.cs b/src/AngleSharp.Js/Dom/PromiseRejectionEvent.cs new file mode 100644 index 0000000..ada434e --- /dev/null +++ b/src/AngleSharp.Js/Dom/PromiseRejectionEvent.cs @@ -0,0 +1,39 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Dom.Events; + using System; + + /// + /// The event fired at the window for a promise rejected without a handler + /// (unhandledrejection), which pages use to report such errors. + /// + [DomName("PromiseRejectionEvent")] + public sealed class PromiseRejectionEvent : Event + { + /// + /// Creates a new event for the given promise. + /// + /// The type of the event, e.g. unhandledrejection. + /// The rejected promise. + /// The value the promise was rejected with. + public PromiseRejectionEvent(String type, Object promise, Object reason) + : base(type, false, true) + { + Promise = promise; + Reason = reason; + } + + /// + /// Gets the rejected promise. + /// + [DomName("promise")] + public Object Promise { get; } + + /// + /// Gets the value the promise was rejected with. + /// + [DomName("reason")] + public Object Reason { get; } + } +} diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index 26b4fda..d1b44fb 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -1,13 +1,17 @@ namespace AngleSharp.Js { + using AngleSharp.Browser; using AngleSharp.Dom; using AngleSharp.Io; using AngleSharp.Js.Cache; + using AngleSharp.Js.Dom; using AngleSharp.Text; using Jint; using Jint.Native; + using Jint.Native.Function; using Jint.Native.Json; using Jint.Native.Object; + using Jint.Native.Promise; using Jint.Runtime; using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; @@ -15,6 +19,7 @@ namespace AngleSharp.Js using System.Collections.Generic; using System.Reflection; using System.Runtime.CompilerServices; + using System.Threading.Tasks; sealed class EngineInstance { @@ -31,6 +36,7 @@ sealed class EngineInstance private readonly LibrarySet _libs; private readonly DomNodeInstance _window; private readonly JsImportMap _importMap; + private readonly List> _unhandledRejections = new List>(); #endregion @@ -81,6 +87,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I } _engine.Global.Prototype = _window.Prototype; + _engine.Advanced.PromiseRejectionTracker += OnPromiseRejection; } #endregion @@ -99,6 +106,152 @@ public EngineInstance(IWindow window, IDictionary assignments, I #region Methods + /// + /// Gives script a promise for the task (e.g. of fetch), settled with the outcome of the + /// task on the event loop of the window - Jint is not thread-safe, and its promise jobs + /// only run when asked to. + /// + public JsValue CreatePromise(Task task) + { + var (promise, resolve, reject) = _engine.Advanced.RegisterPromise(); + var hasResult = GetResultType(task.GetType()) != null; + + task.ContinueWith(completed => Settle(() => + { + if (completed.IsFaulted) + { + var error = completed.Exception.GetBaseException(); + reject(_engine.Intrinsics.Error.Construct(error.Message)); + } + else if (completed.IsCanceled) + { + reject(_engine.Intrinsics.Error.Construct("The operation was aborted.")); + } + else if (hasResult) + { + var result = completed.GetType().GetProperty(nameof(Task.Result)).GetValue(completed); + // untyped, so a node is the same object as the one the DOM gives otherwise + resolve(result.ToJsValue(this)); + } + else + { + resolve(JsValue.Undefined); + } + }), TaskContinuationOptions.ExecuteSynchronously); + + return promise; + } + + // A promise rejected without a handler is reported once the task rejecting it is done, + // which gives the script the rest of it to add one (HTML 8.1.7.3); without an event loop + // there is no end of a task to wait for. + private void OnPromiseRejection(Object sender, PromiseRejectionTrackerEventArgs e) + { + var loop = GetEventLoop(); + + if (loop == null) + { + return; + } + + var index = _unhandledRejections.FindIndex(rejection => ReferenceEquals(rejection.Key, e.Promise)); + + if (e.Operation == PromiseRejectionOperation.Handle) + { + if (index >= 0) + { + _unhandledRejections.RemoveAt(index); + } + } + else if (index < 0) + { + _unhandledRejections.Add(new KeyValuePair(e.Promise, e.Value)); + + if (_unhandledRejections.Count == 1) + { + loop.Enqueue(_ => NotifyUnhandledRejections(), TaskPriority.Normal); + } + } + } + + private void NotifyUnhandledRejections() + { + lock (_engine) + { + var rejections = _unhandledRejections.ToArray(); + _unhandledRejections.Clear(); + + foreach (var rejection in rejections) + { + NotifyUnhandledRejection(rejection.Key, rejection.Value); + } + } + } + + private void NotifyUnhandledRejection(JsValue promise, JsValue reason) + { + var window = (IWindow)_window.Value; + var ev = new PromiseRejectionEvent("unhandledrejection", promise, reason); + window.Dispatch(ev); + + // the handler a script assigned to the window, which is no member of the DOM + if (!ev.IsDefaultPrevented && _window.Get("onunhandledrejection") is Function handler) + { + var result = handler.Call(_window, new JsValue[] { GetDomNode(ev) }); + + if (result.IsBoolean() && !result.AsBoolean()) + { + ev.Cancel(); + } + } + + if (!ev.IsDefaultPrevented) + { + window.Document?.Context?.TrackError(new JavaScriptException(reason)); + } + } + + private void Settle(Action settle) + { + void Run() + { + lock (_engine) + { + settle(); + _engine.Advanced.ProcessTasks(); + } + } + + var loop = GetEventLoop(); + + if (loop != null) + { + loop.Enqueue(_ => Run(), TaskPriority.Normal); + } + else + { + Run(); + } + } + + private IEventLoop GetEventLoop() => (_window.Value as IWindow)?.Document?.Context?.GetService(); + + // The result of a Task (an async method gives a subclass of it); none for a Task, and + // none for the internal VoidTaskResult of a Task completed without a value. + private static Type GetResultType(Type type) + { + for (; type != null && type != typeof(Task); type = type.GetTypeInfo().BaseType) + { + if (type.GetTypeInfo().IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>)) + { + var result = type.GetTypeInfo().GenericTypeArguments[0]; + return result.Name == "VoidTaskResult" ? null : result; + } + } + + return null; + } + public ObjectInstance GetDomNode(Object obj) => _references.GetOrCreate(obj, CreateInstance); public ObjectInstance GetDomNode(Object obj, Type type) => CreateInstance(obj, type); diff --git a/src/AngleSharp.Js/Extensions/DomDelegates.cs b/src/AngleSharp.Js/Extensions/DomDelegates.cs index fbcbee0..d101d89 100644 --- a/src/AngleSharp.Js/Extensions/DomDelegates.cs +++ b/src/AngleSharp.Js/Extensions/DomDelegates.cs @@ -76,15 +76,17 @@ private static Func CreateFactory() var objExpr = Expression.Parameter(typeof(Function), "function"); var engineExpr = Expression.Parameter(typeof(EngineInstance), "engine"); - // The first parameter is the receiver of the call, the others are its arguments; a - // delegate without parameters calls with no receiver. Value types (e.g., the time of + // A leading window (e.g., of a timer or an animation frame) is the receiver of the + // call, all other parameters are its arguments (e.g., the records and the observer + // of a MutationCallback, the node of a NodeFilter). Value types (e.g., the time of // an animation frame) have to be boxed for the conversion. Expression ToJsValue(ParameterExpression m) => Expression.Call(convert, Expression.Convert(m, typeof(Object)), engineExpr); + var hasReceiver = parameters.Length > 0 && typeof(IWindow).IsAssignableFrom(parameters[0].Type); var call = Expression.Call(objExpr, "Call", new Type[0], new Expression[] { - parameters.Length > 0 ? ToJsValue(parameters[0]) : Expression.Constant(JsValue.Undefined, typeof(JsValue)), - Expression.NewArrayInit(typeof(JsValue), parameters.Skip(1).Select(ToJsValue).ToArray()) + hasReceiver ? ToJsValue(parameters[0]) : Expression.Constant(JsValue.Undefined, typeof(JsValue)), + Expression.NewArrayInit(typeof(JsValue), parameters.Skip(hasReceiver ? 1 : 0).Select(ToJsValue).ToArray()) }); var callback = Expression.Lambda(call, parameters); diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index 15258a6..e70be5e 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -14,6 +14,7 @@ namespace AngleSharp.Js using System.Collections.Concurrent; using System.Collections.Generic; using System.Reflection; + using System.Threading.Tasks; static class EngineExtensions { @@ -21,7 +22,11 @@ public static JsValue ToJsValue(this Object obj, EngineInstance engine) { if (obj != null) { - if (obj is String) + if (obj is JsValue value) + { + return value; + } + else if (obj is String) { return JsValue.FromObjectWithType(engine.Jint, obj, typeof(String)); } @@ -59,6 +64,35 @@ public static JsValue ToJsValue(this Object obj, EngineInstance engine) } return JsValue.FromObjectWithType(engine.Jint, obj, typeof(Enum)); } + else if (obj is Task task) + { + return engine.CreatePromise(task); + } + else if (obj is IDictionary record) + { + // A WebIDL record (e.g., the object of a JSON response) arrives as a plain object + var result = new JsObject(engine.Jint); + + foreach (var entry in record) + { + result.FastSetDataProperty(entry.Key, entry.Value.ToJsValue(engine)); + } + + return result; + } + else if (obj is Array array) + { + // A WebIDL sequence (e.g., the records given to a MutationObserver callback) + // arrives as a new JS array, so for...of, spread and the array methods work + var items = new JsValue[array.Length]; + + for (var i = 0; i < items.Length; i++) + { + items[i] = array.GetValue(i).ToJsValue(engine); + } + + return new JsArray(engine.Jint, items); + } return engine.GetDomNode(obj); } diff --git a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs index a916958..ef17d12 100644 --- a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs +++ b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs @@ -9,6 +9,8 @@ namespace AngleSharp.Js using Jint.Runtime.Interop; using System.Linq; using System; + using System.Collections; + using System.Collections.Generic; using System.Reflection; static class JsValueExtensions @@ -39,6 +41,11 @@ private static Object AsComplex(this JsValue value, Type targetType, EngineInsta } } + if (obj is ObjectInstance record && TryCreateRecord(record, targetType, engine, out var entries)) + { + return entries; + } + if (obj is ObjectInstance dictionary && TryCreateDictionary(dictionary, targetType, engine, out var created)) { return created; @@ -55,6 +62,53 @@ private static Object AsComplex(this JsValue value, Type targetType, EngineInsta /// [DomName] (e.g. { top: 100, behavior: 'smooth' } into ScrollToOptions). Members the /// object lacks keep their default. /// + /// + /// A WebIDL record (e.g. the headers of fetch) is given as a plain object, whose own + /// enumerable properties become the entries. + /// + private static Boolean TryCreateRecord(ObjectInstance source, Type targetType, EngineInstance engine, out Object result) + { + var valueType = GetRecordValueType(targetType); + result = null; + + if (valueType == null) + { + return false; + } + + var entries = (IDictionary)Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(typeof(String), valueType)); + + foreach (var property in source.GetOwnProperties()) + { + if (property.Key.IsString() && property.Value.Enumerable) + { + var value = source.Get(property.Key); + entries[property.Key.AsString()] = valueType == typeof(String) ? TypeConverter.ToString(value) : value.As(valueType, engine); + } + } + + result = entries; + return true; + } + + // Dictionary and the interfaces it implements with String keys. + private static Type GetRecordValueType(Type targetType) + { + var typeInfo = targetType.GetTypeInfo(); + + if (typeInfo.IsGenericType && typeInfo.GenericTypeArguments.Length == 2 && typeInfo.GenericTypeArguments[0] == typeof(String)) + { + var dictionary = typeof(Dictionary<,>).MakeGenericType(typeInfo.GenericTypeArguments); + + if (targetType.GetTypeInfo().IsAssignableFrom(dictionary.GetTypeInfo())) + { + return typeInfo.GenericTypeArguments[1]; + } + } + + return null; + } + private static Boolean TryCreateDictionary(ObjectInstance source, Type targetType, EngineInstance engine, out Object result) { var typeInfo = targetType.GetTypeInfo(); diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4063434..8e973b6 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,7 +10,7 @@ entry of CHANGELOG.md, which build/Build.cs passes in as -p:Version, so this value only affects a plain `dotnet build` and no longer needs bumping for a release. --> - 1.1.2 + 1.1.4 From 5079e23b330d0d845b6d7d4bbea2015526ca23b9 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 2 Nov 2026 00:38:46 +0100 Subject: [PATCH 2/2] Use trusted publishing --- .github/workflows/ci.yml | 15 ++++++++++++++- build/Build.cs | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbc8a86..10651af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: [push, pull_request] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} DOCS_PATH: ${{ secrets.DOCS_PATH }} DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }} @@ -60,6 +59,9 @@ jobs: windows: runs-on: windows-latest + permissions: + contents: write # create the GitHub release + id-token: write # request the OIDC token for NuGet trusted publishing steps: - uses: actions/checkout@v7 @@ -69,7 +71,18 @@ jobs: dotnet-version: | 10.0.x + # Exchanges the GitHub OIDC token for a short-lived (1 hour) NuGet API key. + # Requires a trusted publishing policy on nuget.org for this repository and workflow file. + - name: NuGet login + id: nuget_login + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/devel') + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + - name: Build + env: + NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} run: | if ($env:GITHUB_REF -eq "refs/heads/main") { .\build.ps1 -Target Publish -AngleSharpVersion 1.8.0 diff --git a/build/Build.cs b/build/Build.cs index bb06195..650eff7 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -197,9 +197,9 @@ protected override void OnBuildInitialized() .DependsOn(RunUnitTests) .Executes(() => { + // In CI this is the short-lived key issued by NuGet trusted publishing (NuGet/login). var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); - if (apiKey.IsNullOrEmpty()) { throw new BuildAbortedException("Could not resolve the NuGet API key.");